//! Shared sysfs subtree walker for `inspect_full` (Part 13 §5.5.1).
//!
//! Backends (linux_backend / linux_usb) call `walkSubtree` after resolving
//! the URN to a sysfs path; the walker recursively `readdir`s, classifies
//! each entry (file vs symlink vs directory), and pushes the contents into
//! the caller-supplied `kabi.TreeSink`. The sink (`server.zig`'s
//! `TreeJsonWriter`) translates the push sequence into the response JSON.
//!
//! The walker is BOUNDED — three caps:
//!   • `MAX_DEPTH`: recursion can't go deeper than this. A pathological
//!     symlink loop (we don't follow symlinks, but defense in depth) or
//!     a sysfs we don't understand can't blow the stack.
//!   • `MAX_ENTRIES`: total leaf attrs + symlinks emitted across the
//!     whole walk. Caps the response size cleanly without per-call
//!     buffer-pressure heuristics.
//!   • `MAX_FILE_BYTES`: per-attr read limit. Most sysfs attrs are
//!     < 64 bytes; we cap at 4 KiB to bound binary-ish attrs while
//!     letting `report_descriptor`-class attrs through truncated.
//!
//! Symlinks are NOT followed (the walker emits the symlink as a typed
//! leaf — `push_symlink` — and the consumer can decide). Following them
//! would let a single device URN drag in /sys/devices/pciXX/.../driver
//! and re-walk the world.
//!
//! Binary detection: a file's contents are "binary" if any byte is
//! outside ASCII-printable + whitespace (`< 0x20` excluding \t/\n/\r,
//! or `> 0x7E`). Binary files are NOT emitted with their bytes — the
//! sink gets a `push_attr` with `is_binary = true` and an empty value;
//! the TreeJsonWriter emits `{"_binary":true,"size":N}` from that signal.
//! Keeps response sizes bounded + sidesteps JSON-escaping of arbitrary
//! kernel bytes.

const std = @import("std");
const kabi = @import("mgf_hrb_kabi");
const linux = std.os.linux;

/// Selector for the walker's recursion behaviour. Mirrors
/// `model.InspectDepth` — defined here so the walker stays
/// model-agnostic (a future ProcFS walker could reuse the same enum).
pub const WalkMode = enum {
    /// Only emit entries directly inside the starting directory; do
    /// NOT recurse into subdirectories. Maps to `InspectDepth.shallow`.
    shallow,
    /// Recursive walk; emit every descendant up to MAX_DEPTH. Maps to
    /// `InspectDepth.all`.
    full,
};

/// Maximum directory recursion depth. Real sysfs trees are 4-6 deep;
/// 16 is generous headroom + a hard cap against accidents.
pub const MAX_DEPTH: u8 = 16;

/// Maximum total leaf entries (attrs + symlinks) emitted across the walk.
/// Caps the response size at the sink level; the walker stops emitting
/// (but keeps the structure valid by exiting cleanly) once hit.
pub const MAX_ENTRIES: u32 = 512;

/// Maximum bytes read per attr file. Most sysfs attrs are < 64 bytes;
/// the cap absorbs report_descriptor-class attrs (typically <= 256
/// bytes) while bounding the per-attr cost.
pub const MAX_FILE_BYTES: usize = 4096;

/// Per-walk state. The walker mutates these counters; the sink doesn't
/// need to know about them. Use `init()` to construct.
pub const Walker = struct {
    sink: *const kabi.TreeSink,
    entries_emitted: u32 = 0,
    truncated: bool = false,

    pub fn init(sink: *const kabi.TreeSink) Walker {
        return .{ .sink = sink };
    }
};

/// Walk the substrate subtree rooted at `path` (a NUL-terminated
/// absolute path to a directory). `depth` selects shallow vs recursive
/// per `model.InspectDepth`.
///
/// Returns when the walk is done OR `MAX_ENTRIES` is hit. The
/// `Walker.truncated` flag tells the caller which happened. Never
/// raises on filesystem errors — entries that can't be read are
/// skipped silently (per-walk degradation; the global walk continues).
///
/// Why a path-NUL-Z parameter and not a slice: `openat` / `linux.open`
/// want a `[*:0]const u8`. The walker recurses by building NUL-Z
/// paths for subdirs — we lean into the C-ABI shape rather than
/// rebuilding it per call.
pub fn walkSubtree(
    path: [:0]const u8,
    depth: u8,
    mode: WalkMode,
    walker: *Walker,
) void {
    if (depth > MAX_DEPTH) {
        walker.truncated = true;
        return;
    }
    if (walker.entries_emitted >= MAX_ENTRIES) {
        walker.truncated = true;
        return;
    }

    const fd = openDirReadonly(path) orelse return;
    defer _ = linux.close(fd);

    // getdents64 buffer. 4 KiB fits ~30 entries; most sysfs dirs are
    // smaller. The loop refills as we drain.
    var buf: [4096]u8 align(@alignOf(linux.dirent64)) = undefined;
    while (true) {
        const n = linux.getdents64(fd, &buf, buf.len);
        if (linux.errno(n) != .SUCCESS) return;
        if (n == 0) return; // end of directory

        var pos: usize = 0;
        while (pos < n) {
            const dent: *linux.dirent64 = @ptrCast(@alignCast(&buf[pos]));
            pos += dent.reclen;

            // The name is a NUL-terminated string at the tail of the
            // dirent struct, starting at the `name` field offset.
            const name_ptr: [*:0]const u8 = @ptrCast(&dent.name);
            const name = std.mem.span(name_ptr);

            // Skip `.` and `..`.
            if (std.mem.eql(u8, name, ".")) continue;
            if (std.mem.eql(u8, name, "..")) continue;

            // Skip `uevent` — it's a write-only synthesizer in sysfs;
            // reading it sometimes blocks + the content is uninteresting
            // for introspection (it's the file you WRITE to trigger a
            // synthetic uevent, not the device's state).
            if (std.mem.eql(u8, name, "uevent")) continue;

            // Skip `power` and other recursion-heavy "summary" dirs by
            // default? No — leave them; they're real device state. The
            // bounded entry cap handles the size.

            const kind: u8 = dent.type;

            // Bound the per-walk entry count.
            if (walker.entries_emitted >= MAX_ENTRIES) {
                walker.truncated = true;
                return;
            }

            // Build the full path for this entry (NUL-terminated).
            //
            // N6 (review wave 3) — entry_path_buf was [512]u8, too small
            // for deeply-nested sysfs trees (typical /sys/devices/pci.../
            // usbN/N-A.B.C/N-A.B.C:1.0/<entry> can exceed 512 chars under
            // chains of nested USB hubs + ID dirs). On overflow the
            // bufPrintZ would set walker.truncated AND silently skip the
            // entire SUBTREE rooted at this entry. Bumping to 4096 (Linux
            // PATH_MAX) eliminates the silent-skip class for any realistic
            // sysfs depth. Stack cost ~3.5 KiB extra per recursion frame;
            // the recursion is bounded at MAX_DEPTH (=16) so the
            // worst-case stack footprint is ~56 KiB — well within the
            // 8 MiB thread stack.
            var entry_path_buf: [4096]u8 = undefined;
            const entry_path = std.fmt.bufPrintZ(
                &entry_path_buf,
                "{s}/{s}",
                .{ path, name },
            ) catch {
                walker.truncated = true;
                continue;
            };

            switch (kind) {
                // DT_LNK — symlink. Emit as a typed leaf; do NOT follow.
                linux.DT.LNK => emitSymlink(walker, name, entry_path),
                // DT_REG — regular file. Read + emit (with binary detect).
                linux.DT.REG => emitAttr(walker, name, entry_path),
                // DT_DIR — subdirectory. Recurse only in `full` mode.
                linux.DT.DIR => {
                    if (mode == .full) {
                        walker.sink.enter(walker.sink.ctx, name);
                        walkSubtree(entry_path, depth + 1, mode, walker);
                        walker.sink.leave(walker.sink.ctx);
                    }
                },
                // DT_UNKNOWN — some filesystems leave d_type unfilled;
                // sysfs is not one of them. We skip rather than fall
                // back to lstat: Zig 0.16's std.os.linux exposes only
                // the modern `Statx` syscall (not the legacy Stat
                // struct), and adding a statx() dispatch for a path
                // that doesn't fire on sysfs would be cargo-culting.
                // If a future filesystem-backed substrate sees this
                // branch, file a paper and we add the statx fallback.
                linux.DT.UNKNOWN => {},
                // Other types (char/block/fifo/sock) — skip; sysfs
                // doesn't expose them at directory level.
                else => {},
            }
        }
    }
}

fn openDirReadonly(path: [:0]const u8) ?i32 {
    // N6-discussion (review wave 3) — NOFOLLOW would seem defensive,
    // but sysfs's /sys/bus/<bus>/devices/<name> entries are themselves
    // SYMLINKS into /sys/devices/... (it's how Linux exposes the bus
    // view of the device topology). Top-level callers (linux_usb,
    // linux_backend) intentionally hand us those symlink-shaped paths.
    // Adding NOFOLLOW here broke every USB inspect with ELOOP.
    //
    // The walker's switch-table already filters DT_LNK dirents at the
    // walk-loop level (DT_LNK → emitSymlink, never recursion), so
    // INTERNAL recursion never opens a symlink. The substrate-trust
    // boundary is at the TOP-LEVEL CALLER — it picks the path, and
    // sysfs-shaped substrates explicitly want the symlink-following
    // semantics there. A future non-sysfs substrate with hostile-
    // symlink concerns would need a `walkSubtreeNoFollow` variant.
    const rc = linux.open(
        path,
        .{ .ACCMODE = .RDONLY, .DIRECTORY = true },
        0,
    );
    if (linux.errno(rc) != .SUCCESS) return null;
    return @intCast(rc);
}

/// Emit a regular-file leaf. Reads up to MAX_FILE_BYTES, classifies as
/// text vs binary by scanning for non-printable bytes, calls push_attr.
fn emitAttr(walker: *Walker, name: []const u8, path: [:0]const u8) void {
    var buf: [MAX_FILE_BYTES]u8 = undefined;
    // N6 (review wave 3) — NOFOLLOW on attr open (same rationale as
    // openDirReadonly above). The walker's switch already dispatches
    // DT_LNK to emitSymlink (which uses readlink, not open), so an
    // entry classified as DT_REG getting an unexpected symlink-shape
    // is precisely the kind of substrate-trust violation NOFOLLOW
    // catches loudly (ELOOP → open fails → emitAttr returns cleanly,
    // walker.truncated set by the upstream loop iteration if needed).
    const fd = linux.open(path, .{ .ACCMODE = .RDONLY, .NOFOLLOW = true }, 0);
    if (linux.errno(fd) != .SUCCESS) return;
    const fd_i: i32 = @intCast(fd);
    defer _ = linux.close(fd_i);

    const n = linux.read(fd_i, &buf, buf.len);
    if (linux.errno(n) != .SUCCESS) return;

    var content = buf[0..n];
    // Trim trailing whitespace (sysfs attrs canonically end in `\n`).
    while (content.len > 0 and
        (content[content.len - 1] == '\n' or
            content[content.len - 1] == '\r' or
            content[content.len - 1] == ' '))
    {
        content = content[0 .. content.len - 1];
    }

    const binary = isBinary(content);
    if (binary) {
        // Tell the sink it's binary; pass an EMPTY slice (the sink will
        // emit `{"_binary":true,"size":N}` with N taken from... well,
        // we lose size after the trim. Acceptable: the consumer knows
        // it's binary and can `inspect(urn).attr(key)` if they want the
        // bytes. Future: add a separate length param.
        walker.sink.push_attr(walker.sink.ctx, name, "", true);
    } else {
        walker.sink.push_attr(walker.sink.ctx, name, content, false);
    }
    walker.entries_emitted += 1;
}

/// Emit a symlink leaf. readlink() to get the target; do NOT follow.
///
/// N6 (review wave 3) — symlink truncation honesty.
///
/// Per the readlink(2) man page: a return value EQUAL to bufsize
/// means the target MAY have been truncated (the kernel doesn't
/// distinguish "fits exactly" from "fits + more dropped"). Pre-N6
/// the check was `n == 0 or n > buf.len`, and `n > buf.len` is
/// impossible per readlink's contract. We emitted `buf[0..n]`
/// directly when `n == buf.len`, falsely claiming the full target
/// even when truncation could have occurred. The 511-byte case
/// (with buf=[512]) is indistinguishable from 511 exact vs 511+N
/// truncated.
///
/// Fix: pass `buf[0..buf.len-1]` to readlink so the kernel writes
/// at most (cap-1) bytes; if the return equals (cap-1), we KNOW
/// the target was longer and we honestly skip the entry (the sink
/// gets no symlink — consumer sees absence, not a lie). The
/// alternative (emit a truncation marker) would change the sink
/// API; the skip is the minimal-impact honest path.
fn emitSymlink(walker: *Walker, name: []const u8, path: [:0]const u8) void {
    // Use a buf one byte LARGER than the readlink limit so the
    // "fits exactly" case is distinguishable from "would have been
    // longer." Pass the smaller len to readlink; if n equals it, we
    // know the kernel would have written more.
    const LIMIT: usize = 512;
    var buf: [LIMIT + 1]u8 = undefined;
    const rc = linux.readlink(path, &buf, LIMIT);
    if (linux.errno(rc) != .SUCCESS) return;
    const n: usize = rc;
    if (n == 0) return;
    if (n >= LIMIT) {
        // Truncation suspected (target was at least LIMIT bytes — the
        // kernel hit our cap before completing the write). Honest gap:
        // mark walker.truncated so the response carries the
        // truncated:true flag (consumer sees the response was
        // incomplete) and skip emitting a possibly-wrong target.
        walker.truncated = true;
        return;
    }
    walker.sink.push_symlink(walker.sink.ctx, name, buf[0..n]);
    walker.entries_emitted += 1;
}

/// Heuristic binary detection: any byte outside printable-ASCII +
/// whitespace flips the result to binary. Conservative on purpose —
/// false positive (text classified as binary) means a few attrs lose
/// their text content in the response; false NEGATIVE (binary
/// classified as text) would put arbitrary kernel bytes into JSON
/// strings, which is worse.
pub fn isBinary(content: []const u8) bool {
    for (content) |b| {
        if (b == '\t' or b == '\n' or b == '\r') continue;
        if (b < 0x20 or b > 0x7E) return true;
    }
    return false;
}
