//! linux_irq — the real per-device interrupt-counter source for D7 (Tier-2
//! interrupt-rate containment). Plugs into `irq_monitor`'s `IrqCounterSource`
//! seam: given a device URN, return its cumulative interrupt count so the
//! monitor can compute a rate and the broker can revoke + quarantine a flooder.
//!
//! ## Attribution
//!
//! * **PCI** (`hw:/pci/<BDF>/fn*`): the device's IRQ numbers come from
//!   `/sys/bus/pci/devices/<BDF>/msi_irqs/` (one filename per MSI/MSI-X vector),
//!   falling back to the legacy line IRQ in `/sys/bus/pci/devices/<BDF>/irq`.
//!   The count is the sum of the per-CPU columns of those rows in
//!   `/proc/interrupts`.
//! * **USB / everything else**: a USB interface shares its host controller's
//!   IRQ — there is no per-device count to attribute — so this returns null
//!   (the monitor reads null as "skip, can't judge," never "zero"). That's the
//!   honest substrate limit, not a bug.
//!
//! ## Cost
//!
//! Cold path only: it runs when the steal-gated `irq_scan` op samples each
//! granted device, so it re-reads `/proc/interrupts` once per granted device
//! per scan. The granted-device set is small (bounded by the #9 lease ceilings
//! and by how many devices an operator actually hands out), so this is fine; if
//! it ever isn't, a per-scan cache in the source ctx is the obvious follow-on.
//!
//! The PARSERS (`bdfFromPciUrn`, `sumInterruptsForIrqs`) are pure and unit-
//! tested — they're the bug-prone part; the syscall glue around them is thin.

const std = @import("std");
const model = @import("mgf_hrb_model");
const kabi = @import("mgf_hrb_kabi");
const irqmod = @import("mgf_hrb_irq_monitor");

const Urn = model.Urn;
const linux = std.os.linux;
const page_allocator = std.heap.page_allocator;

/// Upper bound on the IRQ vectors we track per device (MSI-X tables can be
/// large; 64 is generous for a single function and bounds the stack array).
const MAX_DEV_IRQS: usize = 64;

/// Read cap for `/proc/interrupts` (1 MiB) — far above any real machine's file,
/// and a hard bound so a pathological `/proc` can't make us allocate unbounded.
const PROC_INTERRUPTS_MAX: usize = 1 << 20;

/// Extract the BDF (`DDDD:BB:DD.F`) from a PCI URN `hw:/pci/<BDF>/fn0`, or null
/// if the URN isn't a PCI device. Pure.
pub fn bdfFromPciUrn(urn: *const Urn) ?[]const u8 {
    const prefix = "hw:/pci/";
    const s = urn.slice();
    if (!std.mem.startsWith(u8, s, prefix)) return null;
    const after = s[prefix.len..];
    const slash = std.mem.indexOfScalar(u8, after, '/') orelse return null;
    const bdf = after[0..slash];
    return if (bdf.len == 0) null else bdf;
}

/// One parsed `/proc/interrupts` row: an IRQ number + the sum of its per-CPU
/// columns. The per-scan snapshot is an array of these, sorted by `irq`.
pub const IrqCount = struct { irq: u32, count: u64 };

fn lessByIrq(_: void, a: IrqCount, b: IrqCount) bool {
    return a.irq < b.irq;
}

/// Parse every numeric IRQ row of `/proc/interrupts` `text` into `out` (one
/// entry per row: the IRQ number + the sum of its per-CPU columns) and SORT the
/// result by IRQ, returning the number stored (capped at `out.len`). Sorting
/// once here lets each per-device lookup binary-search (`lookupSum`) instead of
/// linear-scanning — the win on a big-NUMA / many-MSI-X box. Pure parser — the
/// testable core of the reader.
///
/// Each numeric row is `  <irq>: <cpu0> <cpu1> ... <cpuN>  <controller> <desc>`.
/// We read the leading `<irq>:` label and sum the following whitespace-separated
/// tokens WHILE they parse as integers (the per-CPU counts), stopping at the
/// first non-integer (the controller name). Non-numeric label rows (`NMI:`,
/// `LOC:`, `ERR:`, `MIS:`, …) are skipped, and a colon inside a descriptor (e.g.
/// `ehci_hcd:usb1`) is never reached because we split on the FIRST colon only.
pub fn parseAllInterrupts(text: []const u8, out: []IrqCount) usize {
    var n: usize = 0;
    var lines = std.mem.splitScalar(u8, text, '\n');
    while (lines.next()) |line| {
        if (n >= out.len) break; // S5 bound — a pathological table can't overrun
        const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue;
        const label = std.mem.trim(u8, line[0..colon], " \t");
        const irq = std.fmt.parseInt(u32, label, 10) catch continue; // skip NMI/LOC/…
        var sum: u64 = 0;
        var toks = std.mem.tokenizeAny(u8, line[colon + 1 ..], " \t");
        while (toks.next()) |t| {
            const c = std.fmt.parseInt(u64, t, 10) catch break; // first non-int = controller
            sum +%= c;
        }
        out[n] = .{ .irq = irq, .count = sum };
        n += 1;
    }
    std.mem.sort(IrqCount, out[0..n], {}, lessByIrq);
    return n;
}

fn cmpIrq(key: u32, item: IrqCount) std.math.Order {
    return std.math.order(key, item.irq);
}

/// Sum the snapshot counts for a device's IRQ vectors `device_irqs` against a
/// `snapshot` sorted by IRQ (as `parseAllInterrupts` returns it) — binary search
/// per vector, O(device_irqs · log n). Pure.
pub fn lookupSum(snapshot: []const IrqCount, device_irqs: []const u32) u64 {
    var total: u64 = 0;
    for (device_irqs) |dirq| {
        if (std.sort.binarySearch(IrqCount, snapshot, dirq, cmpIrq)) |idx| {
            total +%= snapshot[idx].count;
        }
    }
    return total;
}

/// Read a small sysfs attribute file into `buf`, trimming trailing newline.
fn readAttr(buf: []u8, path: [:0]const u8) ?[]const u8 {
    const rc = linux.open(path, .{ .ACCMODE = .RDONLY }, 0);
    if (linux.errno(rc) != .SUCCESS) return null;
    const fd: i32 = @intCast(rc);
    defer _ = linux.close(fd);
    const n = linux.read(fd, buf.ptr, buf.len);
    if (linux.errno(n) != .SUCCESS) return null;
    var len: usize = n;
    while (len > 0 and (buf[len - 1] == '\n' or buf[len - 1] == '\r')) len -= 1;
    return buf[0..len];
}

/// Collect a PCI device's IRQ numbers into `out`, returning the count. Prefers
/// the MSI/MSI-X set; falls back to the legacy line IRQ (0 = none).
fn deviceIrqs(bdf: []const u8, out: []u32) usize {
    var n: usize = 0;
    var path_buf: [256]u8 = undefined;

    // MSI/MSI-X: each entry filename under msi_irqs/ is an IRQ number.
    if (std.fmt.bufPrintZ(&path_buf, "/sys/bus/pci/devices/{s}/msi_irqs", .{bdf})) |dir_path| {
        const rc = linux.open(dir_path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true }, 0);
        if (linux.errno(rc) == .SUCCESS) {
            const fd: i32 = @intCast(rc);
            defer _ = linux.close(fd);
            var buf: [4096]u8 align(@alignOf(linux.dirent64)) = undefined;
            scan: while (true) {
                const got = linux.getdents64(fd, &buf, buf.len);
                if (linux.errno(got) != .SUCCESS or got == 0) break;
                var pos: usize = 0;
                while (pos < got) {
                    const dent: *linux.dirent64 = @ptrCast(@alignCast(&buf[pos]));
                    pos += dent.reclen;
                    const name = std.mem.span(@as([*:0]const u8, @ptrCast(&dent.name)));
                    if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) continue;
                    const irq = std.fmt.parseInt(u32, name, 10) catch continue;
                    if (n >= out.len) break :scan;
                    out[n] = irq;
                    n += 1;
                }
            }
        }
    } else |_| {}
    if (n > 0) return n;

    // Fallback: the legacy single line IRQ.
    var attr_buf: [32]u8 = undefined;
    if (std.fmt.bufPrintZ(&path_buf, "/sys/bus/pci/devices/{s}/irq", .{bdf})) |irq_path| {
        if (readAttr(&attr_buf, irq_path)) |s| {
            const irq = std.fmt.parseInt(u32, std.mem.trim(u8, s, " \t\n\r"), 10) catch return 0;
            if (irq != 0) {
                out[0] = irq;
                return 1;
            }
        }
    } else |_| {}
    return 0;
}

/// Read all of `/proc/interrupts` into a freshly-allocated buffer (the file
/// reports size 0, so we read-until-EOF, bounded by `PROC_INTERRUPTS_MAX`).
/// Caller owns + frees the slice.
fn readProcInterrupts() ?[]u8 {
    const rc = linux.open("/proc/interrupts", .{ .ACCMODE = .RDONLY }, 0);
    if (linux.errno(rc) != .SUCCESS) return null;
    const fd: i32 = @intCast(rc);
    defer _ = linux.close(fd);

    var list: std.ArrayListUnmanaged(u8) = .empty;
    var chunk: [8192]u8 = undefined;
    while (list.items.len < PROC_INTERRUPTS_MAX) {
        const n = linux.read(fd, &chunk, chunk.len);
        if (linux.errno(n) != .SUCCESS) {
            list.deinit(page_allocator);
            return null;
        }
        if (n == 0) break;
        list.appendSlice(page_allocator, chunk[0..n]) catch {
            list.deinit(page_allocator);
            return null;
        };
    }
    return list.toOwnedSlice(page_allocator) catch null;
}

/// Max IRQ rows the per-scan snapshot holds — generous for a big NUMA / MSI-X
/// box (each entry is 12 bytes → a ~96 KiB global). Overflow truncates the
/// snapshot (parseAllInterrupts is bounded); a device whose IRQ falls past the
/// cap simply reads 0 for that vector. Logged on overflow would be ideal, but
/// the cap is set far above any real machine's IRQ count.
const SNAPSHOT_MAX: usize = 8192;

/// The per-scan `/proc/interrupts` snapshot. Read + parsed ONCE per scan (via
/// `refresh`, called by `Facade.scanInterruptRates`), then every device's count
/// is a cheap lookup against it — so sweeping N devices costs one shared read,
/// not N. A `SpinMutex` guards it. The lock is held across the `/proc` read in
/// `refreshLocked`, but only on the cold scan path: within a scan, `refresh`
/// runs once up front and the per-device `get`s then do lock-held lookups only
/// (no I/O). Two *concurrent* scans (pathological for a steal-gated operator op)
/// could briefly spin one against the other's single read — acceptable. The
/// arrays are persistent (refilled in place, never freed mid-flight) so a
/// concurrent clobber can't trip a use-after-free; it yields valid (if slightly
/// skewed) counts the rate policy tolerates.
const Snapshot = struct {
    lock: kabi.SpinMutex = .{},
    entries: [SNAPSHOT_MAX]IrqCount = undefined, // sorted by irq after each refresh
    n: usize = 0,
    valid: bool = false,
};
var g_snapshot: Snapshot = .{};

/// (Re)read `/proc/interrupts` into the snapshot. Caller holds `g_snapshot.lock`.
fn refreshLocked() void {
    const text = readProcInterrupts() orelse {
        g_snapshot.valid = false;
        return;
    };
    defer page_allocator.free(text);
    g_snapshot.n = parseAllInterrupts(text, &g_snapshot.entries);
    g_snapshot.valid = true;
}

/// `IrqCounterSource.refresh` — take a fresh `/proc/interrupts` snapshot at the
/// start of a scan.
fn refresh(ctx: *anyopaque) void {
    _ = ctx;
    g_snapshot.lock.lock();
    defer g_snapshot.lock.unlock();
    refreshLocked();
}

/// The `IrqCounterSource.get` implementation: a device's cumulative interrupt
/// count, or null when unattributable (non-PCI URN, or a PCI device with no IRQ
/// assigned). Reads the device's IRQ vectors from sysfs (cheap, per-device) and
/// sums them against the per-scan snapshot. If a standalone caller invokes `get`
/// without a prior `refresh`, it lazy-primes the snapshot once.
fn get(ctx: *anyopaque, urn: *const Urn) ?u64 {
    _ = ctx;
    const bdf = bdfFromPciUrn(urn) orelse return null; // USB / other → not attributable
    var device_irqs: [MAX_DEV_IRQS]u32 = undefined;
    const k = deviceIrqs(bdf, &device_irqs);
    if (k == 0) return null; // device exists but has no IRQ assigned → nothing to judge

    g_snapshot.lock.lock();
    defer g_snapshot.lock.unlock();
    if (!g_snapshot.valid) refreshLocked(); // lazy prime when used outside a scan
    if (!g_snapshot.valid) return null; // /proc read failed
    return lookupSum(g_snapshot.entries[0..g_snapshot.n], device_irqs[0..k]);
}

var g_ctx: u8 = 0;

/// The production interrupt-counter source — wire this into the broker at
/// startup (`irq_monitor.setCounterSource`). `refresh` snapshots `/proc/interrupts`
/// once per scan; `get` serves each device from that snapshot.
pub fn counterSource() irqmod.IrqCounterSource {
    return .{ .ctx = &g_ctx, .get = get, .refresh = refresh };
}

// ===========================================================================
// D7 — pure-parser pins (the bug-prone half; the syscall glue is thin).
// ===========================================================================

const testing = std.testing;

const SAMPLE_PROC_INTERRUPTS =
    \\           CPU0       CPU1
    \\  0:         12          0   IO-APIC    2-edge      timer
    \\ 16:        100        200   IO-APIC   16-fasteoi   ehci_hcd:usb1
    \\120:    1000000          0   PCI-MSI 327680-edge      nvme0q0
    \\121:          0      50000   PCI-MSI 327681-edge      nvme0q1
    \\NMI:          5          7   Non-maskable interrupts
    \\LOC:     123456     123457   Local timer interrupts
;

test "D7 parse: parse+sort the table once, then binary-search per-device counts" {
    var buf: [16]IrqCount = undefined;
    const n = parseAllInterrupts(SAMPLE_PROC_INTERRUPTS, &buf);
    // Four numeric rows (0, 16, 120, 121); the NMI/LOC rows have non-numeric
    // labels and are skipped even though their counts dwarf the device rows.
    try testing.expectEqual(@as(usize, 4), n);
    // Sorted ascending by IRQ — the precondition lookupSum's binary search needs.
    try testing.expect(buf[0].irq == 0 and buf[1].irq == 16 and buf[2].irq == 120 and buf[3].irq == 121);

    const snap = buf[0..n];
    try testing.expectEqual(@as(u64, 1_000_000), lookupSum(snap, &.{120}));
    try testing.expectEqual(@as(u64, 1_050_000), lookupSum(snap, &.{ 120, 121 }));
    try testing.expectEqual(@as(u64, 300), lookupSum(snap, &.{16})); // summed per-CPU cols
    try testing.expectEqual(@as(u64, 0), lookupSum(snap, &.{999})); // absent IRQ
    try testing.expectEqual(@as(u64, 12), lookupSum(snap, &.{0}));
    try testing.expectEqual(@as(u64, 0), lookupSum(snap, &.{})); // no device IRQs
}

test "D7 parse: lookupSum binary-searches correctly even when /proc rows aren't pre-sorted" {
    // /proc/interrupts is conventionally ascending, but don't rely on it — feed
    // descending rows and prove parse+sort makes the binary search correct.
    const unsorted =
        \\121:    0    50000   PCI-MSI 327681-edge nvme0q1
        \\120:    1000000  0   PCI-MSI 327680-edge nvme0q0
        \\ 16:    100  200     IO-APIC 16-fasteoi  ehci_hcd:usb1
    ;
    var buf: [16]IrqCount = undefined;
    const n = parseAllInterrupts(unsorted, &buf);
    const snap = buf[0..n];
    try testing.expect(buf[0].irq == 16 and buf[1].irq == 120 and buf[2].irq == 121); // sorted
    try testing.expectEqual(@as(u64, 1_050_000), lookupSum(snap, &.{ 121, 120 }));
    try testing.expectEqual(@as(u64, 300), lookupSum(snap, &.{16}));
}

test "D7 parse: the snapshot is bounded — a table larger than the cap truncates" {
    var buf: [2]IrqCount = undefined; // tiny cap
    const n = parseAllInterrupts(SAMPLE_PROC_INTERRUPTS, &buf);
    try testing.expectEqual(@as(usize, 2), n); // stopped at the cap, no overrun
}

test "D7 parse: BDF extraction from a PCI URN; non-PCI URNs yield null" {
    const pci = Urn.from("hw:/pci/0000:01:00.0/fn0");
    try testing.expect(std.mem.eql(u8, "0000:01:00.0", bdfFromPciUrn(&pci).?));
    // USB and malformed URNs are not attributable.
    try testing.expect(bdfFromPciUrn(&Urn.from("hw:/usb/bus1/dev4/if0")) == null);
    try testing.expect(bdfFromPciUrn(&Urn.from("hw:/pci/")) == null); // no BDF segment
}

test "D7 reader: USB + nonexistent-PCI URNs read as null (unattributable)" {
    const src = counterSource();
    // USB is never attributable (shares the HCD IRQ).
    try testing.expect(src.get(src.ctx, &Urn.from("hw:/usb/bus1/dev4/if0")) == null);
    // A PCI BDF that doesn't exist on this host → no sysfs dir → no IRQs → null.
    try testing.expect(src.get(src.ctx, &Urn.from("hw:/pci/dead:ff:1f.7/fn0")) == null);
}

test "D7 reader (host integration): get() is total over every real PCI device" {
    // Exercise the real /sys + /proc path against whatever PCI devices this host
    // actually has. The property under test is ROBUSTNESS: get() must be total
    // (never crash, always null-or-number) over real-world sysfs/proc shapes,
    // and where a device has IRQs the full attribution path runs for real. On a
    // host with no PCI sysfs (a minimal container) this iterates nothing and
    // passes — the unit tests above already cover the logic deterministically.
    const rc = linux.open("/sys/bus/pci/devices", .{ .ACCMODE = .RDONLY, .DIRECTORY = true }, 0);
    if (linux.errno(rc) != .SUCCESS) return; // no PCI sysfs → nothing to exercise
    const fd: i32 = @intCast(rc);
    defer _ = linux.close(fd);

    const src = counterSource();
    var buf: [4096]u8 align(@alignOf(linux.dirent64)) = undefined;
    while (true) {
        const got = linux.getdents64(fd, &buf, buf.len);
        if (linux.errno(got) != .SUCCESS or got == 0) break;
        var pos: usize = 0;
        while (pos < got) {
            const dent: *linux.dirent64 = @ptrCast(@alignCast(&buf[pos]));
            pos += dent.reclen;
            const name = std.mem.span(@as([*:0]const u8, @ptrCast(&dent.name)));
            if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) continue;
            var urn_buf: [128]u8 = undefined;
            const urn_str = std.fmt.bufPrint(&urn_buf, "hw:/pci/{s}/fn0", .{name}) catch continue;
            const urn = Urn.from(urn_str);
            _ = src.get(src.ctx, &urn); // full real path runs; must stay total (no crash)
        }
    }
    // Reaching here means get() stayed total across every real PCI device on this
    // host (no hard count assertion — attribution is host-dependent).
}
