//! mgf-hrb-usb-synth — the watch synthesizer (Part 5.3.4).
//!
//! Wraps a raw `EventSink` and intercepts USB add/remove pairs that arrive
//! within a per-bus debounce window. When the runtime identity comparator
//! (`identity.zig`) agrees that the freshly-added device is "the same
//! physical unit" as the one that just dropped, the pair is collapsed
//! into a single `state_changed { old: absent, new: free, previous_urn }`
//! event — so consumers see one logical transition rather than a
//! flicker. Below confidence threshold, both events flow through as-is.
//!
//! Why this lives here (and not in Core):
//!   • It is USB-bus-specific behaviour — driven by the kernel's
//!     remove/add pattern on bus enumeration, mode-change (DFU, composite
//!     reconfiguration), or a quick re-plug.
//!   • It is pure data — no syscalls, no allocations beyond a small
//!     bounded buffer, fully unit-testable without netlink.
//!   • The result feeds the same `EventSink` everything else uses; the
//!     wire format extends `state_changed` with `previous_urn` so naive
//!     consumers still get the URN they expect.
//!
//! Lifetime guarantees:
//!   • Pending `removed` events are held for at most `debounce_ms`. On
//!     `flushExpired()` (called at the head of each pump iteration) the
//!     stash is drained — no event is ever silently dropped. A
//!     debounce-expired removal is forwarded as the original `removed`.
//!   • Bounded by `MAX_PENDING`. If a flood pushes the stash past that
//!     cap, the OLDEST pending removed is forwarded as `removed`
//!     (preserving the gap-honesty discipline) to make room.
//!
//! G2 honesty: never claim more confidence than the comparator returns.
//! The collapse only fires at `Confidence.likely_same` or stronger.

const std = @import("std");
const model = @import("mgf_hrb_model");
const kabi = @import("mgf_hrb_kabi");
const identity = @import("mgf_hrb_identity");

const Urn = model.Urn;
const Event = model.Event;
const State = model.State;
const Snapshot = identity.Snapshot;
const Confidence = identity.Confidence;
const ComparatorOpts = identity.ComparatorOpts;

/// Maximum number of pending removed events held during the debounce
/// window. A modest cap — a busy bus rarely has more than a handful of
/// simultaneous unplugs.
pub const MAX_PENDING: usize = 16;

/// Default debounce window. Matches `ComparatorOpts.port_debounce_ms`
/// (1 s) — within this window a remove+add with comparator agreement
/// reads as "the same device flickered" rather than two distinct
/// physical events.
pub const DEFAULT_DEBOUNCE_MS: u32 = 1000;

const PendingRemoved = struct {
    snapshot: Snapshot,
    urn: Urn,
    expires_ms: u64,
};

/// The watch synthesizer. Construct with `init`; feed events with
/// `onAdded` / `onRemoved`; call `flushExpired` at the head of each
/// pump iteration so debounce-expired removes are not held forever.
///
/// All emission goes through the wrapped `out` sink — the synthesizer
/// itself produces no events directly.
pub const Synthesizer = struct {
    debounce_ms: u32 = DEFAULT_DEBOUNCE_MS,
    /// Comparator tunables for the same_likely check. The defaults are
    /// the design's per-USB-bus defaults (allow mode-change PID flip;
    /// strict on cable move; tolerate missing serial on one side).
    opts: ComparatorOpts = .{},

    pending: [MAX_PENDING]PendingRemoved = undefined,
    pending_len: usize = 0,

    pub fn init(debounce_ms: u32) Synthesizer {
        return .{ .debounce_ms = debounce_ms };
    }

    /// Forward an `added` event through the synthesizer. The caller
    /// supplies a snapshot built from sysfs at add-time. If a pending
    /// `removed` snapshot matches at `likely_same` or stronger, the
    /// pair is collapsed into a single `state_changed` and neither
    /// the original `removed` nor the `added` is emitted.
    pub fn onAdded(
        self: *Synthesizer,
        urn: Urn,
        snap: Snapshot,
        sink: *const kabi.EventSink,
    ) void {
        if (self.bestMatch(&snap)) |idx| {
            const prev = self.pending[idx];
            self.removeAt(idx);
            sink.push(sink.ctx, .{ .state_changed = .{
                .urn = urn,
                .old = .absent,
                .new = .free,
                .previous_urn = prev.urn,
            } });
            return;
        }
        sink.push(sink.ctx, .{ .added = .{ .urn = urn } });
    }

    /// Stash a `removed` event for the debounce window. The event is
    /// NOT emitted yet — `flushExpired` will forward it as a plain
    /// `removed` once the window passes without a matching `added`.
    /// The caller supplies the snapshot it had cached at add-time (so
    /// we have something to compare against later).
    pub fn onRemoved(
        self: *Synthesizer,
        urn: Urn,
        snap: Snapshot,
        sink: *const kabi.EventSink,
        now_ms: u64,
    ) void {
        if (self.pending_len == MAX_PENDING) {
            // Cap reached: forward the oldest pending immediately as
            // a plain `removed` to make room. Preserves gap-honesty
            // — we never silently drop.
            const oldest = self.pending[0];
            self.removeAt(0);
            sink.push(sink.ctx, .{ .removed = .{ .urn = oldest.urn } });
        }
        self.pending[self.pending_len] = .{
            .snapshot = snap,
            .urn = urn,
            .expires_ms = now_ms +| self.debounce_ms,
        };
        self.pending_len += 1;
    }

    /// Forward any pending `removed` whose debounce window has passed.
    /// Call at the head of each pump iteration (or on a timer); safe
    /// to call when nothing is pending.
    pub fn flushExpired(
        self: *Synthesizer,
        sink: *const kabi.EventSink,
        now_ms: u64,
    ) void {
        var i: usize = 0;
        while (i < self.pending_len) {
            if (now_ms >= self.pending[i].expires_ms) {
                const ev = self.pending[i];
                self.removeAt(i);
                sink.push(sink.ctx, .{ .removed = .{ .urn = ev.urn } });
                // index stays — removeAt shifted the next entry into i
            } else {
                i += 1;
            }
        }
    }

    /// Forward every pending `removed` immediately. Used on
    /// shutdown so the consumer never loses an event to a partial
    /// debounce window.
    pub fn drainAll(self: *Synthesizer, sink: *const kabi.EventSink) void {
        var i: usize = 0;
        while (i < self.pending_len) : (i += 1) {
            const ev = self.pending[i];
            sink.push(sink.ctx, .{ .removed = .{ .urn = ev.urn } });
        }
        self.pending_len = 0;
    }

    /// Walk the pending list and return the index of the strongest
    /// match for `snap`, or `null` if no match reaches `likely_same`.
    fn bestMatch(self: *Synthesizer, snap: *const Snapshot) ?usize {
        // N5 (review wave 3) — phantom-snapshot guard.
        //
        // Pre-N5: when `readUsbSnapshot` failed at the linux_usb
        // layer (sysfs already gone, race with kernel hot-remove),
        // the caller built a fallback Snapshot with vid=0/pid=0.
        // That phantom landed in `self.pending` here. The
        // `sameLikely` comparator's vid-match check (`a.vid == b.vid`)
        // is FALSE-POSITIVE for two zero values: the new device's
        // snapshot also briefly reads as vid=0 if its sysfs is mid-
        // population, OR more critically, two *different* devices
        // that both phantom'd would be treated as the same. The
        // resulting `LIKELY_SAME` collapses two unrelated devices
        // into one `state_changed` event — silent fidelity loss (G2
        // violation) and a real consumer-facing bug.
        //
        // Fix: refuse to match against ANY pending snapshot whose
        // vid is 0 (or whose comparison subject's vid is 0). USB
        // vendor IDs start at 0x0001 per USB-IF; vid=0 is a sentinel
        // for "no real identity captured." A phantom-on-both-sides
        // is honestly indistinguishable, so we DON'T collapse — the
        // event flushes as a plain `removed` + plain `added`
        // (debounce-window expiry handles the timing). That's the
        // honest behaviour: when we lack identity, we report
        // discontinuity rather than fake continuity.
        if (snap.vid == 0) return null;

        var best_idx: ?usize = null;
        var best_score: u8 = @intFromEnum(Confidence.ambiguous);
        var i: usize = 0;
        while (i < self.pending_len) : (i += 1) {
            // Skip phantom-pending entries — same rationale.
            if (self.pending[i].snapshot.vid == 0) continue;
            const conf = identity.sameLikely(&self.pending[i].snapshot, snap, self.opts);
            const score = @intFromEnum(conf);
            if (score >= @intFromEnum(Confidence.likely_same) and score > best_score) {
                best_score = score;
                best_idx = i;
            }
        }
        return best_idx;
    }

    fn removeAt(self: *Synthesizer, idx: usize) void {
        var i = idx;
        while (i + 1 < self.pending_len) : (i += 1) {
            self.pending[i] = self.pending[i + 1];
        }
        self.pending_len -= 1;
    }
};

// === tests ================================================================
const testing = std.testing;

const CollectingSink = struct {
    events: std.ArrayListUnmanaged(Event) = .empty,
    alloc: std.mem.Allocator,

    fn push(ctx: *anyopaque, ev: Event) void {
        const self: *CollectingSink = @ptrCast(@alignCast(ctx));
        self.events.append(self.alloc, ev) catch {};
    }

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

    fn deinit(self: *CollectingSink) void {
        self.events.deinit(self.alloc);
    }
};

fn mkSnap(args: struct {
    urn: []const u8,
    vid: u16,
    pid: u16,
    serial: ?[]const u8 = null,
    bus: u8 = 3,
    port_chain: []const u8 = &.{9},
    captured_at_ms: u64 = 1_000_000,
}) Snapshot {
    return Snapshot.init(.{
        .urn = Urn.from(args.urn),
        .vid = args.vid,
        .pid = args.pid,
        .serial_number = args.serial,
        .bus = args.bus,
        .port_chain = args.port_chain,
        .captured_at_ms = args.captured_at_ms,
    });
}

test "synth: a plain add with no pending removed flows through as `added`" {
    var col = CollectingSink{ .alloc = testing.allocator };
    defer col.deinit();
    const out = col.sink();

    var s = Synthesizer.init(1000);
    s.onAdded(
        Urn.from("hw:/usb/bus3/port-9/if0"),
        mkSnap(.{ .urn = "hw:/usb/bus3/port-9/if0", .vid = 0x1234, .pid = 0x5678 }),
        &out,
    );

    try testing.expectEqual(@as(usize, 1), col.events.items.len);
    try testing.expect(col.events.items[0] == .added);
}

test "synth: a remove without a matching add is stashed, not emitted" {
    var col = CollectingSink{ .alloc = testing.allocator };
    defer col.deinit();
    const out = col.sink();

    var s = Synthesizer.init(1000);
    s.onRemoved(
        Urn.from("hw:/usb/bus3/port-9/if0"),
        mkSnap(.{ .urn = "hw:/usb/bus3/port-9/if0", .vid = 0x1234, .pid = 0x5678 }),
        &out,
        1_000_000,
    );

    try testing.expectEqual(@as(usize, 0), col.events.items.len); // held during debounce
    try testing.expectEqual(@as(usize, 1), s.pending_len);
}

test "synth: stashed remove is flushed as `removed` after debounce expires" {
    var col = CollectingSink{ .alloc = testing.allocator };
    defer col.deinit();
    const out = col.sink();

    var s = Synthesizer.init(1000);
    s.onRemoved(
        Urn.from("hw:/usb/bus3/port-9/if0"),
        mkSnap(.{ .urn = "hw:/usb/bus3/port-9/if0", .vid = 0x1234, .pid = 0x5678 }),
        &out,
        1_000_000,
    );

    s.flushExpired(&out, 1_000_500); // 500 ms — still inside window
    try testing.expectEqual(@as(usize, 0), col.events.items.len);

    s.flushExpired(&out, 1_001_500); // 1500 ms — past window
    try testing.expectEqual(@as(usize, 1), col.events.items.len);
    try testing.expect(col.events.items[0] == .removed);
    try testing.expectEqual(@as(usize, 0), s.pending_len);
}

test "synth: remove+add of the same physical device collapses to `state_changed`" {
    var col = CollectingSink{ .alloc = testing.allocator };
    defer col.deinit();
    const out = col.sink();

    var s = Synthesizer.init(1000);

    // Device unplugs (interface 0).
    s.onRemoved(
        Urn.from("hw:/usb/bus3/port-9/if0"),
        mkSnap(.{
            .urn = "hw:/usb/bus3/port-9/if0",
            .vid = 0x1234,
            .pid = 0x5678,
            .serial = "ABC-001",
        }),
        &out,
        1_000_000,
    );

    // 200 ms later: same VID, same PID, same serial — comparator says SAME.
    s.onAdded(
        Urn.from("hw:/usb/bus3/port-9/if0"),
        mkSnap(.{
            .urn = "hw:/usb/bus3/port-9/if0",
            .vid = 0x1234,
            .pid = 0x5678,
            .serial = "ABC-001",
            .captured_at_ms = 1_000_200,
        }),
        &out,
    );

    try testing.expectEqual(@as(usize, 1), col.events.items.len);
    try testing.expect(col.events.items[0] == .state_changed);
    const sc = col.events.items[0].state_changed;
    try testing.expect(sc.old == .absent);
    try testing.expect(sc.new == .free);
    try testing.expect(sc.previous_urn != null);
    try testing.expect(std.mem.eql(u8, "hw:/usb/bus3/port-9/if0", sc.previous_urn.?.slice()));
    try testing.expectEqual(@as(usize, 0), s.pending_len); // pair consumed
}

test "synth: remove+add of a DIFFERENT device emits both events (no collapse)" {
    var col = CollectingSink{ .alloc = testing.allocator };
    defer col.deinit();
    const out = col.sink();

    var s = Synthesizer.init(1000);

    s.onRemoved(
        Urn.from("hw:/usb/bus3/port-9/if0"),
        mkSnap(.{
            .urn = "hw:/usb/bus3/port-9/if0",
            .vid = 0x1234,
            .pid = 0x5678,
            .serial = "ABC-001",
        }),
        &out,
        1_000_000,
    );
    // Different VID → comparator short-circuits to DIFFERENT.
    s.onAdded(
        Urn.from("hw:/usb/bus3/port-9/if0"),
        mkSnap(.{
            .urn = "hw:/usb/bus3/port-9/if0",
            .vid = 0x9999, // different vendor
            .pid = 0x5678,
            .serial = "XYZ-002",
            .captured_at_ms = 1_000_200,
        }),
        &out,
    );

    // The `added` flows through immediately. The `removed` is still
    // stashed (its debounce hasn't expired). flushExpired drains it.
    try testing.expectEqual(@as(usize, 1), col.events.items.len);
    try testing.expect(col.events.items[0] == .added);

    s.flushExpired(&out, 1_001_500);
    try testing.expectEqual(@as(usize, 2), col.events.items.len);
    try testing.expect(col.events.items[1] == .removed);
}

test "synth: drainAll forwards every pending removed at shutdown" {
    var col = CollectingSink{ .alloc = testing.allocator };
    defer col.deinit();
    const out = col.sink();

    var s = Synthesizer.init(1000);
    s.onRemoved(
        Urn.from("hw:/usb/bus3/port-9/if0"),
        mkSnap(.{ .urn = "hw:/usb/bus3/port-9/if0", .vid = 1, .pid = 1 }),
        &out,
        1_000_000,
    );
    s.onRemoved(
        Urn.from("hw:/usb/bus3/port-9/if1"),
        mkSnap(.{ .urn = "hw:/usb/bus3/port-9/if1", .vid = 1, .pid = 1 }),
        &out,
        1_000_000,
    );

    try testing.expectEqual(@as(usize, 0), col.events.items.len);
    s.drainAll(&out);
    try testing.expectEqual(@as(usize, 2), col.events.items.len);
    try testing.expectEqual(@as(usize, 0), s.pending_len);
}

test "synth: pending cap forwards oldest as plain removed when MAX_PENDING is hit" {
    var col = CollectingSink{ .alloc = testing.allocator };
    defer col.deinit();
    const out = col.sink();

    var s = Synthesizer.init(1000);

    // Fill to capacity.
    var i: usize = 0;
    while (i < MAX_PENDING) : (i += 1) {
        var urn_buf: [128]u8 = undefined;
        const urn_str = std.fmt.bufPrint(&urn_buf, "hw:/usb/bus3/port-9/if{d}", .{i}) catch unreachable;
        const urn = Urn.from(urn_str);
        s.onRemoved(
            urn,
            mkSnap(.{ .urn = "ignored", .vid = @intCast(i + 1), .pid = 0 }),
            &out,
            1_000_000,
        );
    }
    try testing.expectEqual(MAX_PENDING, s.pending_len);
    try testing.expectEqual(@as(usize, 0), col.events.items.len);

    // One more → oldest gets forwarded.
    s.onRemoved(
        Urn.from("hw:/usb/bus3/port-9/ifZ"),
        mkSnap(.{ .urn = "ignored", .vid = 99, .pid = 0 }),
        &out,
        1_000_000,
    );
    try testing.expectEqual(@as(usize, 1), col.events.items.len);
    try testing.expect(col.events.items[0] == .removed);
    try testing.expectEqual(MAX_PENDING, s.pending_len); // still at cap
}
