#!/usr/bin/env perl
#
# sysop_info.pl - a read-only system information collector for sysops.
#
#   * Detects macOS (Darwin) vs Linux and picks tools accordingly.
#   * Runs read-only probes only: no sudo, no writes, no config changes,
#     no process signalling of anything other than probes it spawned itself.
#   * Prints a plain-text report (optionally JSON for machine consumption).
#
# Usage:
#   perl sysop_info.pl              # full report
#   perl sysop_info.pl --json       # same data as JSON
#   perl sysop_info.pl --only disk  # one section (substring match)
#   perl sysop_info.pl --no-color   # disable ANSI colour
#   perl sysop_info.pl --help
#
use strict;
use warnings;
use POSIX qw(strftime);
use Getopt::Long qw(GetOptions);

our $VERSION = '1.0.0';

# ---------------------------------------------------------------------------
# Options
# ---------------------------------------------------------------------------
my %opt = (
    json    => 0,
    color   => (-t STDOUT ? 1 : 0),
    only    => undef,
    timeout => 10,
    help    => 0,
);

GetOptions(
    'json'      => \$opt{json},
    'color!'    => \$opt{color},
    'only=s'    => \$opt{only},
    'timeout=i' => \$opt{timeout},
    'help|h'    => \$opt{help},
) or usage(1);

usage(0) if $opt{help};

$opt{timeout} = 2  if $opt{timeout} < 2;
$opt{timeout} = 60 if $opt{timeout} > 60;

# ---------------------------------------------------------------------------
# Platform detection
# ---------------------------------------------------------------------------
sub detect_os {
    # Test hook: lets the Linux code paths be exercised on other platforms.
    if (my $f = $ENV{SYSOP_FORCE_OS}) {
        return $f if $f eq 'linux' || $f eq 'darwin' || $f eq 'unknown';
    }
    return 'darwin' if $^O =~ /darwin/i;
    return 'linux'  if $^O =~ /linux/i;

    my ($u) = run(5, 'uname', '-s');
    $u = lc($u // '');
    $u =~ s/\s+//g;
    return 'darwin' if $u eq 'darwin';
    return 'linux'  if $u eq 'linux';
    return 'unknown';
}

my $OS = detect_os();

# Root of the procfs tree. Overridable (SYSOP_PROC_ROOT / --proc-root) purely
# so the Linux code paths can be exercised and regression-tested off-Linux.
my $PROC_ROOT = $ENV{SYSOP_PROC_ROOT} || '/proc';
sub proc_path { my ($rel) = @_; $rel =~ s{^/}{}; return "$PROC_ROOT/$rel" }

# ---------------------------------------------------------------------------
# Command execution (read-only, list form, no shell, with a timeout)
# ---------------------------------------------------------------------------
sub run {
    my ($timeout, @cmd) = @_;
    $timeout = $opt{timeout} if !defined $timeout;
    return ('', 127) unless @cmd;

    pipe(my $rd, my $wr) or return ('', 127);
    my $pid = fork;
    if (!defined $pid) {
        close $rd; close $wr;
        return ('', 127);
    }
    if ($pid == 0) {
        # Child: stdout -> pipe, stderr discarded. Never use a shell.
        close $rd;
        open STDOUT, '>&', $wr or POSIX::_exit(127);
        open STDERR, '>', '/dev/null' or POSIX::_exit(127);
        exec @cmd or POSIX::_exit(127);
    }
    close $wr;

    my $out = '';
    my $deadline = time + $timeout;
    my $rin = '';
    vec($rin, fileno($rd), 1) = 1;
    my $timedout = 0;

    while (1) {
        my $remaining = $deadline - time;
        if ($remaining <= 0) { $timedout = 1; last; }
        my $r = select(my $rout = $rin, undef, undef, $remaining);
        last if !defined $r;      # select error
        if ($r == 0) { $timedout = 1; last; }
        my $buf;
        my $n = sysread($rd, $buf, 65536);
        last if !defined $n || $n == 0;
        $out .= $buf;
    }
    close $rd;

    # Only ever signal the probe we spawned ourselves.
    kill 'TERM', $pid if $timedout;
    waitpid($pid, 0);

    my $rc = $timedout ? 124 : ($? >> 8);
    return ($out, $rc);
}

sub which {
    my ($name) = @_;
    return undef unless defined $name && length $name;
    for my $dir (split /:/, ($ENV{PATH} // '')) {
        next unless length $dir;
        my $p = "$dir/$name";
        return $p if -x $p && !-d _;
    }
    return undef;
}

sub have { return defined which($_[0]) ? 1 : 0 }

sub first_line {
    my ($s) = @_;
    return '' unless defined $s;
    for my $l (split /\n/, $s) {
        $l =~ s/^\s+|\s+$//g;
        return $l if length $l;
    }
    return '';
}

# ---------------------------------------------------------------------------
# Small formatters
# ---------------------------------------------------------------------------
sub hbytes {
    my ($b) = @_;
    return 'n/a' unless defined $b && $b =~ /^\d+$/;
    my @u = qw(B KiB MiB GiB TiB PiB);
    my $i = 0;
    my $v = $b + 0.0;
    while ($v >= 1024 && $i < $#u) { $v /= 1024; $i++ }
    return sprintf($i == 0 ? '%d %s' : '%.1f %s', $v, $u[$i]);
}

sub hseconds {
    my ($s) = @_;
    return 'n/a' unless defined $s && $s =~ /^\d+$/;
    my $d = int($s / 86400);
    my $h = int(($s % 86400) / 3600);
    my $m = int(($s % 3600) / 60);
    return $d > 0 ? sprintf('%dd %dh %02dm', $d, $h, $m)
                  : sprintf('%dh %02dm', $h, $m);
}

sub now_str { return strftime('%Y-%m-%d %H:%M:%S %z', localtime) }

# ---------------------------------------------------------------------------
# Section bookkeeping
# ---------------------------------------------------------------------------
my @sections;
my @all_warnings;

sub section {
    my (%args) = @_;
    my $s = {
        name     => $args{name},
        rows     => $args{rows}     || [],
        notes    => $args{notes}    || [],
        warnings => $args{warnings} || [],
    };
    push @all_warnings, map { "[$args{name}] $_" } @{ $s->{warnings} };
    push @sections, $s;
    return $s;
}

sub add_row { my ($s, $k, $v) = @_; push @{ $s->{rows} }, [ $k, $v ] }

# ===========================================================================
# 1. Identity & OS
# ===========================================================================
sub collect_identity {
    my @rows;
    my @notes;

    my $host = first_line(do { my ($o) = run(5, 'hostname'); $o });
    $host = $ENV{HOSTNAME} // '' unless length $host;
    push @rows, [ 'Hostname', $host || 'unknown' ];

    my ($user) = (getpwuid($<))[0];
    push @rows, [ 'Collected as', ($user // 'unknown') . ' (uid ' . $< . ')' ];
    push @rows, [ 'Collected at', now_str() ];
    push @rows, [ 'Perl', sprintf('%vd on %s', $^V, $^O) ];

    if ($OS eq 'darwin') {
        my ($name) = run(5, 'sw_vers', '-productName');
        my ($ver)  = run(5, 'sw_vers', '-productVersion');
        my ($bld)  = run(5, 'sw_vers', '-buildVersion');
        push @rows, [ 'OS', trim($name) || 'macOS (Darwin)' ];
        push @rows, [ 'OS version', trim($ver) || 'unknown' ];
        push @rows, [ 'OS build', trim($bld) || 'unknown' ];
        my ($model) = run(5, 'sysctl', '-n', 'hw.model');
        push @rows, [ 'Hardware model', trim($model) || 'unknown' ];
        my ($arch) = run(5, 'uname', '-m');
        push @rows, [ 'Architecture', trim($arch) || 'unknown' ];
        push @rows, [ 'Kernel', trim(first_line(do { my ($o) = run(5, 'uname', '-r'); $o })) ];
    }
    elsif ($OS eq 'linux') {
        my $pretty = '';
        if (open my $fh, '<', '/etc/os-release') {
            while (my $l = <$fh>) {
                if ($l =~ /^PRETTY_NAME="?([^"\n]+)"?/) { $pretty = $1; last }
            }
            close $fh;
        }
        push @rows, [ 'OS', $pretty || 'Linux' ];
        push @rows, [ 'Kernel', trim(first_line(do { my ($o) = run(5, 'uname', '-r'); $o })) ];
        my ($arch) = run(5, 'uname', '-m');
        push @rows, [ 'Architecture', trim($arch) || 'unknown' ];
        if (-r '/etc/machine-id') {
            my $id = do { local (@ARGV, $/) = ('/etc/machine-id'); <> };
            push @rows, [ 'Machine ID', trim($id) ] if defined $id;
        }
    }
    else {
        my ($u) = run(5, 'uname', '-a');
        push @rows, [ 'OS', 'unknown (uname: ' . trim(first_line($u)) . ')' ];
        my ($arch) = run(5, 'uname', '-m');
        push @rows, [ 'Architecture', trim($arch) || 'unknown' ];
        push @notes, 'Unrecognised platform; using generic probes.';
    }

    return section(name => 'Identity & OS', rows => \@rows, notes => \@notes);
}

# ===========================================================================
# 2. Uptime & Load
# ===========================================================================
sub collect_uptime_load {
    my @rows;
    my @warnings;
    my $cores = cpu_count() || 1;
    my ($up_sec, $boot_str);

    if ($OS eq 'darwin') {
        my ($bt) = run(5, 'sysctl', '-n', 'kern.boottime');
        if ($bt =~ /sec\s*=\s*(\d+)/) {
            $up_sec = time() - $1;
            $boot_str = strftime('%Y-%m-%d %H:%M:%S', localtime($1));
        }
    }
    elsif ($OS eq 'linux' && open my $fh, '<', proc_path('uptime')) {
        my $l = <$fh>; close $fh;
        $up_sec = int($1) if defined $l && $l =~ /^([\d.]+)/;
        if (defined $up_sec) {
            $boot_str = strftime('%Y-%m-%d %H:%M:%S', localtime(time() - $up_sec));
        }
    }

    push @rows, [ 'Uptime', hseconds($up_sec) ];
    push @rows, [ 'Boot time', $boot_str // 'unknown' ];

    my ($l1, $l5, $l15) = loadavg();
    if (defined $l1) {
        push @rows, [ 'Load average', sprintf('%.2f  %.2f  %.2f  (1/5/15 min)', $l1, $l5, $l15) ];
        my $pct = $cores > 0 ? 100.0 * $l1 / $cores : 0;
        push @rows, [ 'Load per core', sprintf('%.2f of %d cores (%.0f%%)', $l1 / $cores, $cores, $pct) ];
        if ($pct >= 100) {
            push @warnings, sprintf('1-min load %.2f exceeds %d cores - CPU saturation likely', $l1, $cores);
        }
        elsif ($pct >= 75) {
            push @warnings, sprintf('1-min load %.2f is %.0f%% of %d cores - running hot', $l1, $pct, $cores);
        }
    }
    else {
        push @rows, [ 'Load average', 'unavailable' ];
    }

    if ($OS eq 'darwin') {
        my ($up) = run(5, 'uptime');
        my $line = trim(first_line($up));
        push @rows, [ 'uptime(1)', $line ] if length $line;
    }

    return section(name => 'Uptime & Load', rows => \@rows, warnings => \@warnings);
}

sub loadavg {
    if ($OS eq 'darwin') {
        my ($o) = run(5, 'sysctl', '-n', 'vm.loadavg');
        my @n = $o =~ /(\d+\.\d+)/g;
        return @n[0 .. 2] if @n >= 3;
    }
    if (open my $fh, '<', proc_path('loadavg')) {
        my $l = <$fh>; close $fh;
        if (defined $l && $l =~ /^([\d.]+)\s+([\d.]+)\s+([\d.]+)/) {
            return ($1, $2, $3);
        }
    }
    return;
}

sub cpu_count {
    if ($OS eq 'darwin') {
        my ($o) = run(5, 'sysctl', '-n', 'hw.logicalcpu');
        return int($1) if $o =~ /(\d+)/;
        ($o) = run(5, 'sysctl', '-n', 'hw.ncpu');
        return int($1) if $o =~ /(\d+)/;
    }
    elsif (open my $fh, '<', proc_path('cpuinfo')) {
        my $n = 0;
        while (my $l = <$fh>) { $n++ if $l =~ /^processor\s*:/ }
        close $fh;
        return $n if $n > 0;
    }
    my ($o) = run(5, 'getconf', '_NPROCESSORS_ONLN');
    return int($1) if $o =~ /(\d+)/;
    return 0;
}

# ===========================================================================
# 3. CPU
# ===========================================================================
sub collect_cpu {
    my @rows;
    my $logical  = cpu_count();
    my $physical = 0;
    my $model    = '';

    if ($OS eq 'darwin') {
        my ($m) = run(5, 'sysctl', '-n', 'machdep.cpu.brand_string');
        $model = trim($m);
        my ($p) = run(5, 'sysctl', '-n', 'hw.physicalcpu');
        $physical = int($1) if $p =~ /(\d+)/;
    }
    elsif ($OS eq 'linux') {
        if (have('lscpu')) {
            my ($o) = run(5, 'lscpu');
            my ($cps, $sock) = (0, 0);
            for my $l (split /\n/, $o) {
                $model = trim($1) if !$model && $l =~ /^Model name:\s+(.+)$/;
                $cps  = int($1) if $l =~ /^Core\(s\) per socket:\s+(\d+)/;
                $sock = int($1) if $l =~ /^Socket\(s\):\s+(\d+)/;
            }
            $physical = $cps * ($sock || 1);
        }
        if (!$model && open my $fh, '<', proc_path('cpuinfo')) {
            while (my $l = <$fh>) {
                if ($l =~ /^(?:model name|Processor)\s*:\s*(.+)$/) { $model = trim($1); last }
            }
            close $fh;
        }
    }

    push @rows, [ 'Model', $model || 'unknown' ];
    push @rows, [ 'Logical CPUs', $logical || 'unknown' ];
    push @rows, [ 'Physical cores', $physical || 'unknown' ] if $physical;
    push @rows, [ 'Architecture', trim(first_line(do { my ($o) = run(5, 'uname', '-m'); $o })) ];

    if ($OS eq 'darwin') {
        my ($freq) = run(5, 'sysctl', '-n', 'hw.cpufrequency_max');
        if ($freq =~ /(\d+)/ && $1 > 0) {
            push @rows, [ 'Max CPU freq', sprintf('%.2f GHz', $1 / 1e9) ];
        }
    }

    return section(name => 'CPU', rows => \@rows);
}

# ===========================================================================
# 4. Memory & Swap
# ===========================================================================
sub collect_memory {
    my @rows;
    my @warnings;
    my $total = 0;

    if ($OS eq 'darwin') {
        my ($ms) = run(5, 'sysctl', '-n', 'hw.memsize');
        $total = int($1) if $ms =~ /(\d+)/;

        my ($vm) = run(5, 'vm_stat');
        my $page = 0;
        $page = int($1) if $vm =~ /page size of (\d+)/;
        $page ||= 4096;

        my %pg;
        for my $line (split /\n/, $vm) {
            if ($line =~ /^"?([^":]+?)"?:\s+(\d+)\.?\s*$/) {
                $pg{ trim($1) } = int($2);
            }
        }
        my $p = sub { ($pg{ $_[0] } // 0) * $page };

        my $free      = $p->('Pages free');
        my $inactive  = $p->('Pages inactive');
        my $active    = $p->('Pages active');
        my $wired     = $p->('Pages wired down');
        my $spec      = $p->('Pages speculative');
        my $comp      = $p->('Pages occupied by compressor');
        my $used      = $active + $wired + $comp;
        my $available = $free + $inactive + $spec;

        push @rows, [ 'Total RAM', hbytes($total) ];
        push @rows, [ 'Used (active+wired+compressed)', hbytes($used) ];
        push @rows, [ 'Available (free+inactive+speculative)', hbytes($available) ];
        push @rows, [ 'App/wired/compressed', sprintf('%s / %s / %s',
            hbytes($active), hbytes($wired), hbytes($comp)) ];
        if ($total > 0) {
            my $pct = 100.0 * $used / $total;
            push @rows, [ 'Used %', sprintf('%.1f%%', $pct) ];
            push @warnings, sprintf('memory in use is %.0f%% of %s', $pct, hbytes($total))
                if $pct >= 90;
        }
        push @rows, [ 'Page size', hbytes($page) ];
        push @rows, [ 'Pageins / Pageouts',
            sprintf('%s / %s', $pg{'Pageins'} // 0, $pg{'Pageouts'} // 0) ];

        my ($sw) = run(5, 'sysctl', '-n', 'vm.swapusage');
        if ($sw =~ /total\s*=\s*([\d.]+)([KMG])/i) {
            my ($tval, $tunit) = ($1, uc $2);
            my %mult = (K => 1024, M => 1024**2, G => 1024**3);
            my $tbytes = $tval * ($mult{$tunit} // 1);
            my ($uval, $uunit) = ($sw =~ /used\s*=\s*([\d.]+)([KMG])/i);
            my $ubytes = defined $uval ? $uval * ($mult{ uc($uunit // 'M') } // 1) : 0;
            push @rows, [ 'Swap', sprintf('%s used of %s', hbytes($ubytes), hbytes($tbytes)) ];
            if ($tbytes > 0) {
                my $pct = 100.0 * $ubytes / $tbytes;
                push @warnings, sprintf('swap %.0f%% used (%s of %s)', $pct, hbytes($ubytes), hbytes($tbytes))
                    if $pct >= 50;
            }
        }
    }
    else {
        my %mem;
        if (open my $fh, '<', proc_path('meminfo')) {
            while (my $l = <$fh>) {
                if ($l =~ /^(\w+):\s+(\d+)\s*kB/) { $mem{$1} = int($2) * 1024 }
            }
            close $fh;
        }
        $total = $mem{MemTotal} // 0;
        my $free  = $mem{MemFree} // 0;
        my $avail = $mem{MemAvailable} // ($free + ($mem{Cached} // 0) + ($mem{Buffers} // 0));
        my $used  = $total > 0 ? $total - $avail : 0;

        push @rows, [ 'Total RAM', hbytes($total) ];
        push @rows, [ 'Used', hbytes($used) ];
        push @rows, [ 'Available', hbytes($avail) ];
        push @rows, [ 'Free / Buffers / Cached', sprintf('%s / %s / %s',
            hbytes($free), hbytes($mem{Buffers} // 0), hbytes($mem{Cached} // 0)) ];
        if ($total > 0) {
            my $pct = 100.0 * $used / $total;
            push @rows, [ 'Used %', sprintf('%.1f%%', $pct) ];
            push @warnings, sprintf('memory in use is %.0f%% of %s', $pct, hbytes($total))
                if $pct >= 90;
        }
        my $swtotal = $mem{SwapTotal} // 0;
        my $swfree  = $mem{SwapFree}  // 0;
        my $swused  = $swtotal - $swfree;
        push @rows, [ 'Swap', $swtotal > 0
            ? sprintf('%s used of %s', hbytes($swused), hbytes($swtotal))
            : 'none configured' ];
        if ($swtotal > 0 && $swused / $swtotal >= 0.5) {
            push @warnings, sprintf('swap %.0f%% used (%s of %s)',
                100.0 * $swused / $swtotal, hbytes($swused), hbytes($swtotal));
        }
        push @rows, [ 'Dirty pages', hbytes($mem{Dirty} // 0) ] if exists $mem{Dirty};
    }

    # Linux memory pressure (PSI), if exposed and readable.
    if ($OS eq 'linux' && open my $fh, '<', proc_path('pressure/memory')) {
        my @lines = <$fh>; close $fh;
        my ($avg) = grep { /^some\s/ } @lines;
        if (defined $avg && $avg =~ /avg10=([\d.]+)/) {
            push @rows, [ 'Pressure (some avg10)', sprintf('%.2f%%', $1) ];
        }
    }

    return section(name => 'Memory & Swap', rows => \@rows, warnings => \@warnings);
}

# ===========================================================================
# 5. Disk / Filesystems
# ===========================================================================
sub is_pseudo_fs {
    my ($fs) = @_;
    return 1 if $fs =~ m{^(devfs|map\s+auto_home|nullfs|autofs|proc|sysfs|debugfs|securityfs|pstore|cgroup|cgroup2|tracefs|configfs|mqueue|hugetlbfs|fusectl|binfmt_misc|rpc_pipefs|nsfs|overlay)$};
    return 1 if $fs =~ m{^(none|udev|tmpfs|devtmpfs|shm)$};
    return 0;
}

sub collect_disk {
    my @rows;
    my @warnings;
    my @notes;

    my ($df) = run(undef, 'df', '-Pk');
    my @entries;
    for my $line (split /\n/, $df) {
        next if $line =~ /^\s*Filesystem/ || !length trim($line);
        my @f = split /\s+/, $line, 6;
        next if @f < 6;
        my ($fs, $kblocks, $used, $avail, $cap, $mount) = @f;
        next unless $kblocks =~ /^\d+$/;
        my $pct = $cap =~ /(\d+)%/ ? $1 : 0;
        push @entries, {
            fs     => $fs,
            total  => $kblocks * 1024,
            used   => $used  * 1024,
            avail  => $avail * 1024,
            pct    => $pct,
            mount  => $mount,
            pseudo => (is_pseudo_fs($fs) || $mount =~ m{^/(?:dev|proc|sys)(?:/|$)}) ? 1 : 0,
        };
    }

    # Real (non-pseudo, non-zero) filesystems get the detail.
    my @real = grep { !$_->{pseudo} && $_->{total} > 0 } @entries;
    if (@real) {
        my $w = 0;
        for my $e (@real) {
            my $l = length($e->{mount});
            $w = $l if $l > $w;
        }
        $w = 28 if $w > 28;
        for my $e (sort { $b->{pct} <=> $a->{pct} } @real) {
            my $bar = usage_bar($e->{pct});
            push @rows, [ sprintf('%-*s', $w, $e->{mount}),
                          sprintf('%8s total  %8s used  %8s free  %3d%% %s',
                              hbytes($e->{total}), hbytes($e->{used}),
                              hbytes($e->{avail}), $e->{pct}, $bar) ];
            push @warnings, sprintf('%s is %d%% full (%s free)', $e->{mount}, $e->{pct}, hbytes($e->{avail}))
                if $e->{pct} >= 85;
        }
    }
    else {
        push @rows, [ 'Filesystems', 'no sized filesystems reported by df' ];
    }

    # Inode usage where the platform reports it usefully.
    my ($dfi) = run(undef, 'df', '-Pi');
    my %inode_by_mount;
    for my $line (split /\n/, $dfi) {
        next if $line =~ /^\s*Filesystem/ || !length trim($line);
        my @f = split /\s+/, $line;
        if ($OS eq 'darwin' && @f >= 9) {
            my $mount = join(' ', @f[8 .. $#f]);
            $inode_by_mount{$mount} = int($1) if $f[7] =~ /^(\d+)%$/;
        }
        elsif ($OS eq 'linux' && @f >= 6) {
            my $mount = join(' ', @f[5 .. $#f]);
            my $pct = $f[4];
            $inode_by_mount{$mount} = $1 if $pct =~ /(\d+)%/;
        }
    }
    my @inode_hot;
    for my $e (@real) {
        my $pct = $inode_by_mount{ $e->{mount} };
        next unless defined $pct;
        push @inode_hot, [ $e->{mount}, $pct ] if $pct >= 80;
        push @warnings, sprintf('%s inode usage is %d%%', $e->{mount}, $pct) if $pct >= 90;
    }
    if (@inode_hot) {
        push @rows, [ 'Inode pressure', join('; ', map { sprintf('%s %d%%', @$_) } @inode_hot) ];
    }

    # Pseudo filesystems, summarised so the report stays readable.
    my @pseudo = grep { $_->{pseudo} && $_->{total} > 0 } @entries;
    if (@pseudo) {
        push @notes, sprintf('%d pseudo/virtual filesystems omitted from detail (devfs, tmpfs, proc, ...)', scalar @pseudo);
    }

    if ($OS eq 'darwin' && have('diskutil')) {
        my ($di) = run(undef, 'diskutil', 'info', '/');
        my %info;
        for my $l (split /\n/, $di) {
            if ($l =~ /^\s*([^:]+?):\s*(.*\S)\s*$/) { $info{ trim($1) } = trim($2) }
        }
        push @rows, [ 'Root volume', join(', ',
            grep { defined && length }
            map { $info{$_} ? "$_: $info{$_}" : () }
            ('Volume Name', 'File System Personality', 'Device Identifier')) ];
    }

    # Mounts that are read-only are worth flagging for a sysop.
    my $mounts = first_line(do { my ($o) = run(5, 'mount'); $o });
    if ($OS eq 'linux' && open my $fh, '<', proc_path('mounts')) {
        my @ro;
        while (my $l = <$fh>) {
            my @f = split /\s+/, $l;
            next if @f < 4;
            push @ro, $f[1] if $f[3] =~ /(?:^|,)ro(?:,|$)/ && !is_pseudo_fs($f[2]);
        }
        close $fh;
        push @notes, 'read-only mounts: ' . join(', ', @ro) if @ro;
    }

    return section(name => 'Disk / Filesystems', rows => \@rows, notes => \@notes, warnings => \@warnings);
}

sub usage_bar {
    my ($pct) = @_;
    my $n = int($pct / 10 + 0.5);
    $n = 0 if $n < 0;
    $n = 10 if $n > 10;
    return '[' . ('#' x $n) . ('.' x (10 - $n)) . ']';
}

# ===========================================================================
# 6. Network
# ===========================================================================
sub collect_network {
    my @rows;
    my @warnings;
    my @notes;

    # Default gateway(s)
    my @gw;
    if ($OS eq 'darwin') {
        my ($o) = run(undef, 'netstat', '-rn');
        my %seen;
        for my $l (split /\n/, $o) {
            next unless $l =~ /^default\s/;
            my @f = split /\s+/, $l;
            my $g = $f[1];
            next unless defined $g && length $g;
            next if $g =~ /^link#/;                 # interface-scoped route
            next if $g =~ /%|^fe80:/i;              # link-local IPv6 noise
            push @gw, $g unless $seen{$g}++;
        }
    }
    elsif ($OS eq 'linux') {
        my ($o) = run(undef, 'ip', 'route', 'show', 'default');
        if ($o =~ /default via (\S+)\s+dev\s+(\S+)/) { push @gw, "$1 dev $2" }
        if (!@gw && -r proc_path('net/route')) {
            if (open my $fh, '<', proc_path('net/route')) {
                while (my $l = <$fh>) {
                    my @f = split /\s+/, $l;
                    next unless @f >= 3 && $f[1] eq '00000000';
                    my @b = reverse split //, $f[2];
                    my $ip = join('.', map { hex } grep { length } ($f[2] =~ /(..)(..)(..)(..)/));
                    push @gw, $ip;
                }
                close $fh;
            }
        }
    }
    push @rows, [ 'Default gateway', @gw ? join(', ', @gw) : 'none detected' ];

    # DNS resolvers
    my @ns;
    if ($OS eq 'darwin' && have('scutil')) {
        my ($o) = run(undef, 'scutil', '--dns');
        my %seen;
        for my $l (split /\n/, $o) {
            if ($l =~ /nameserver\[\d+\]\s*:\s*(\S+)/) {
                push @ns, $1 unless $seen{$1}++;
            }
        }
    }
    if (!@ns && open my $fh, '<', '/etc/resolv.conf') {
        while (my $l = <$fh>) {
            push @ns, $1 if $l =~ /^\s*nameserver\s+(\S+)/;
        }
        close $fh;
    }
    push @rows, [ 'DNS resolvers', @ns ? join(', ', @ns) : 'none detected' ];

    # Interfaces with addresses
    my @ifaces;
    if ($OS eq 'darwin') {
        my ($o) = run(undef, 'ifconfig');
        my ($cur, %state);
        for my $l (split /\n/, $o) {
            if ($l =~ /^(\S+):\s+flags=/) {
                $cur = $1;
                push @ifaces, { name => $cur, addrs => [], state => '' };
            }
            elsif (defined $cur) {
                if ($l =~ /^\s+inet\s+(\d+\.\d+\.\d+\.\d+)/) {
                    push @{ $ifaces[-1]{addrs} }, $1;
                }
                elsif ($l =~ /^\s+inet6\s+([0-9a-f:]+)/i) {
                    my $a = $1;
                    push @{ $ifaces[-1]{addrs} }, $a unless $a =~ /^fe80:/i;
                }
                elsif ($l =~ /^\s+status:\s+(\w+)/) {
                    $ifaces[-1]{state} = $1;
                }
            }
        }
    }
    elsif ($OS eq 'linux') {
        my %by_name;
        my ($addr) = run(undef, 'ip', '-o', 'addr', 'show');
        for my $l (split /\n/, $addr) {
            next unless $l =~ /^\d+:\s+(\S+?):?\s+inet6?\s+(\S+)/;
            my ($n, $a) = ($1, $2);
            $a =~ s{/.*$}{};
            $by_name{$n} ||= { name => $n, addrs => [], state => '' };
            push @{ $by_name{$n}{addrs} }, $a unless $a =~ /^fe80:/i;
        }
        my ($link) = run(undef, 'ip', '-o', 'link', 'show');
        for my $l (split /\n/, $link) {
            next unless $l =~ /^\d+:\s+(\S+?):?\s+.*?\bstate\s+(\S+)/;
            my ($n, $st) = ($1, $2);
            $by_name{$n} ||= { name => $n, addrs => [], state => '' };
            $by_name{$n}{state} = lc $st;
        }
        @ifaces = map { $by_name{$_} } sort keys %by_name;
    }

    my @active = grep { @{ $_->{addrs} } } @ifaces;
    if (@active) {
        for my $i (@active) {
            my $state = $i->{state} ? " ($i->{state})" : '';
            my @a = @{ $i->{addrs} };
            my $shown = join(', ', @a[0 .. ($#a > 2 ? 2 : $#a)]);
            $shown .= sprintf(' (+%d more)', @a - 3) if @a > 3;
            push @rows, [ $i->{name} . $state, $shown ];
        }
    }
    else {
        push @rows, [ 'Interfaces', 'no addresses parsed' ];
    }
    push @notes, sprintf('%d interfaces total, %d with addresses', scalar @ifaces, scalar @active);

    # Listening TCP sockets
    my (@listen, $established);
    if ($OS eq 'darwin') {
        my ($o) = run(undef, 'netstat', '-an', '-p', 'tcp');
        my %ports;
        for my $l (split /\n/, $o) {
            if ($l =~ /^\S+\s+\d+\s+\d+\s+(\S+)\s+\S+\s+LISTEN/) {
                my $lp = $1;
                my ($port) = $lp =~ /\.(\d+)$/;
                next unless defined $port;
                $ports{$port} = $lp;
            }
            $established++ if $l =~ /ESTABLISHED/;
        }
        @listen = sort { $a <=> $b } keys %ports;
    }
    elsif ($OS eq 'linux') {
        my ($o) = run(undef, 'ss', '-tlnH');
        if (!length $o && have('netstat')) {
            ($o) = run(undef, 'netstat', '-tln');
            $o = join("\n", grep { !/^Proto/ } split /\n/, $o);
        }
        my %ports;
        for my $l (split /\n/, $o) {
            my @f = split /\s+/, trim($l);
            my $lp = $f[3] // next;
            my ($port) = $lp =~ /:(\d+)$/;
            $ports{$port} = $lp if defined $port;
        }
        @listen = sort { $a <=> $b } keys %ports;
        my ($es) = run(undef, 'ss', '-tn', 'state', 'established');
        $established = scalar grep { /\S/ } split /\n/, $es;
    }
    else {
        my ($o) = run(undef, 'netstat', '-an');
        my %ports;
        for my $l (split /\n/, $o) {
            if ($l =~ /LISTEN/) {
                my ($lp) = $l =~ /^\S+\s+\d+\s+\d+\s+(\S+)/;
                my ($port) = ($lp // '') =~ /[.:](\d+)$/;
                $ports{$port} = 1 if defined $port;
            }
        }
        @listen = sort { $a <=> $b } keys %ports;
    }

    push @rows, [ 'Listening TCP ports', @listen
        ? sprintf('%d (%s)', scalar @listen, join(', ', @listen[0 .. ($#listen > 14 ? 14 : $#listen)]) . ($#listen > 14 ? ', ...' : ''))
        : 'none detected' ];
    push @rows, [ 'Established TCP', defined $established ? $established : 'unknown' ];

    my @up = grep { $_->{state} =~ /^(up|active)$/i } @ifaces;
    push @rows, [ 'Interfaces up', scalar @up ? join(', ', map { $_->{name} } @up) : 'none reported' ]
        if @up;

    return section(name => 'Network', rows => \@rows, notes => \@notes, warnings => \@warnings);
}

# ===========================================================================
# 7. Top Processes
# ===========================================================================
sub collect_processes {
    my @rows;
    my @notes;

    my ($ps) = run(undef, 'ps', '-Ao', 'pid=,pcpu=,pmem=,rss=,comm=');
    my @procs;
    for my $l (split /\n/, $ps) {
        next unless $l =~ /^\s*(\d+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(.*\S)\s*$/;
        push @procs, {
            pid  => int($1),
            cpu  => $2 + 0,
            mem  => $3 + 0,
            rss  => int($4) * 1024,
            comm => $5,
        };
    }

    if (@procs) {
        my @by_cpu = sort { $b->{cpu} <=> $a->{cpu} || $b->{mem} <=> $a->{mem} } @procs;
        my @by_mem = sort { $b->{rss} <=> $a->{rss} } @procs;

        push @rows, [ 'Total processes', scalar @procs ];
        push @rows, [ 'By CPU (top 5)', '' ];
        for my $p (@by_cpu[0 .. ($#by_cpu > 4 ? 4 : $#by_cpu)]) {
            push @rows, [ sprintf('  pid %-7d %5.1f%% cpu', $p->{pid}, $p->{cpu}),
                          sprintf('%6.1f%% mem  %s  %s', $p->{mem}, hbytes($p->{rss}), shorten($p->{comm}, 60)) ];
        }
        push @rows, [ 'By memory (top 5)', '' ];
        for my $p (@by_mem[0 .. ($#by_mem > 4 ? 4 : $#by_mem)]) {
            push @rows, [ sprintf('  pid %-7d %6s rss', $p->{pid}, hbytes($p->{rss})),
                          sprintf('%6.1f%% mem  %5.1f%% cpu  %s', $p->{mem}, $p->{cpu}, shorten($p->{comm}, 60)) ];
        }

        my $zombies = scalar grep { $_->{comm} =~ /defunct/i } @procs;
        push @rows, [ 'Zombie/defunct', $zombies ] if $zombies;
    }
    else {
        push @rows, [ 'Processes', 'ps output unavailable' ];
    }

    return section(name => 'Top Processes', rows => \@rows, notes => \@notes);
}

sub shorten {
    my ($s, $n) = @_;
    $s = '' unless defined $s;
    return $s if length($s) <= $n;
    return substr($s, 0, $n - 3) . '...';
}

# ===========================================================================
# 8. Logged-in Users
# ===========================================================================
sub collect_users {
    my @rows;
    my ($who) = run(undef, 'who');
    my @lines = grep { /\S/ } split /\n/, $who;
    push @rows, [ 'Logged-in sessions', scalar @lines ];
    for my $l (@lines) {
        my @f = split /\s+/, $l;
        my $user = shift @f // '?';
        my $tty  = shift @f // '?';
        push @rows, [ sprintf('  %s', $tty), sprintf('%s  %s', $user, join(' ', @f)) ];
    }
    if ($OS eq 'darwin' && have('who')) {
        my ($last) = run(undef, 'last', '-5');
        my @ll = grep { /\S/ } split /\n/, $last;
        if (@ll) {
            push @rows, [ 'Recent logins', '' ];
            push @rows, [ '  ' . trim($_), '' ] for @ll[0 .. ($#ll > 4 ? 4 : $#ll)];
        }
    }
    return section(name => 'Logged-in Users', rows => \@rows);
}

# ===========================================================================
# 9. Security Posture (read-only checks)
# ===========================================================================
sub collect_security {
    my @rows;
    my @warnings;

    if ($OS eq 'darwin') {
        my ($sip) = run(undef, 'csrutil', 'status');
        my $sip_l = trim(first_line($sip));
        push @rows, [ 'System Integrity Protection', $sip_l || 'unknown' ];
        push @warnings, 'SIP does not appear to be enabled' if $sip_l =~ /disabled/i;

        my ($fv) = run(undef, 'fdesetup', 'status');
        my $fv_l = trim(first_line($fv));
        push @rows, [ 'FileVault (disk encryption)', $fv_l || 'unknown (needs privileges?)' ];
        push @warnings, 'FileVault is off - disk is not encrypted' if $fv_l =~ /\boff\b/i;

        my ($gate) = run(undef, 'spctl', '--status');
        push @rows, [ 'Gatekeeper', trim(first_line($gate)) || 'unknown' ];

        my $fw = '';
        my $sfw = '/usr/libexec/ApplicationFirewall/socketfilterfw';
        if (-x $sfw) {
            my ($o) = run(undef, $sfw, '--getglobalstate');
            $fw = trim(first_line($o));
        }
        elsif (have('socketfilterfw')) {
            my ($o) = run(undef, 'socketfilterfw', '--getglobalstate');
            $fw = trim(first_line($o));
        }
        if (!length $fw && have('defaults')) {
            my ($o) = run(undef, 'defaults', 'read', '/Library/Preferences/com.apple.alf', 'globalstate');
            $fw = trim(first_line($o));
        }
        push @rows, [ 'Application firewall', $fw || 'unknown (needs privileges?)' ];
        if ($fw =~ /disabled|State\s*=\s*0/i) {
            push @warnings, 'macOS application firewall is disabled';
        }
    }
    elsif ($OS eq 'linux') {
        my $selinux = 'not present';
        if (-r '/sys/fs/selinux/enforce') {
            my $e = do { local (@ARGV, $/) = ('/sys/fs/selinux/enforce'); <> };
            $selinux = trim($e) eq '1' ? 'enforcing' : 'permissive';
        }        elsif (have('getenforce')) {
            my ($o) = run(undef, 'getenforce');
            $selinux = trim(first_line($o)) || $selinux;
        }
        push @rows, [ 'SELinux', $selinux ];

        my $lsm = 'unknown';
        if (-r '/sys/kernel/security/lsm') {
            my $l = do { local (@ARGV, $/) = ('/sys/kernel/security/lsm'); <> };
            $lsm = trim($l) if defined $l;
        }
        push @rows, [ 'Active LSMs', $lsm ];

        my @fw;
        for my $unit (qw(firewalld ufw nftables iptables)) {
            my ($o) = run(5, 'systemctl', 'is-active', $unit);
            my $st = trim(first_line($o));
            push @fw, "$unit=$st" if length $st && $st ne 'unknown';
        }
        push @rows, [ 'Firewall units', @fw ? join(', ', @fw) : 'not detected via systemctl' ];
        push @warnings, 'no active host firewall unit detected' unless grep { /=active/ } @fw;
    }
    else {
        push @rows, [ 'Security checks', 'not implemented for this platform' ];
    }

    push @rows, [ 'Unprivileged report', 'no sudo used; privileged-only checks may read "unknown"' ];

    return section(name => 'Security Posture', rows => \@rows, warnings => \@warnings);
}

# ===========================================================================
# Build the report
# ===========================================================================
sub trim { my ($s) = @_; $s = '' unless defined $s; $s =~ s/^\s+|\s+$//g; return $s }

my @builders = (
    [ 'identity',  \&collect_identity ],
    [ 'uptime',    \&collect_uptime_load ],
    [ 'cpu',       \&collect_cpu ],
    [ 'memory',    \&collect_memory ],
    [ 'disk',      \&collect_disk ],
    [ 'network',   \&collect_network ],
    [ 'processes', \&collect_processes ],
    [ 'users',     \&collect_users ],
    [ 'security',  \&collect_security ],
);

for my $b (@builders) {
    my ($tag, $fn) = @$b;
    next if defined $opt{only} && index($tag, lc $opt{only}) < 0;
    my $s = eval { $fn->() };
    if (!$s) {
        my $err = $@ || 'unknown error';
        push @sections, {
            name     => ucfirst($tag),
            rows     => [ [ 'Error', trim(first_line($err)) ] ],
            notes    => [],
            warnings => [],
        };
    }
}

# ===========================================================================
# Render: JSON
# ===========================================================================
if ($opt{json}) {
    my $ok = eval { require JSON::PP; 1 };
    if (!$ok) {
        print STDERR "sysop_info: JSON::PP not available; falling back to text\n";
        $opt{json} = 0;
    }
    else {
        my $data = {
            schema      => 'sysop_info/1',
            version     => $VERSION,
            generated   => now_str,
            platform    => $OS,
            hostname    => trim(first_line(do { my ($o) = run(5, 'hostname'); $o })),
            sections    => [
                map {
                    {
                        name     => $_->{name},
                        rows     => [ map { { key => $_->[0], value => $_->[1] } } @{ $_->{rows} } ],
                        notes    => $_->{notes},
                        warnings => $_->{warnings},
                    }
                } @sections
            ],
            warnings    => \@all_warnings,
        };
        print JSON::PP->new->canonical->pretty->utf8->encode($data);
        exit 0;
    }
}

# ===========================================================================
# Render: plain text
# ===========================================================================
my %C = $opt{color}
    ? ( bold => "\e[1m", dim => "\e[2m", red => "\e[31m", yellow => "\e[33m",
        green => "\e[32m", cyan => "\e[36m", reset => "\e[0m" )
    : map { $_ => '' } qw(bold dim red yellow green cyan reset);

my $WIDTH = 78;

sub hr { return $C{dim} . ('-' x $WIDTH) . $C{reset} }

print $C{bold}, "SYSTEM INFO REPORT", $C{reset}, "\n";
print $C{dim}, "generated " . now_str() . "  |  platform: $OS  |  read-only probes, no sudo", $C{reset}, "\n";

for my $s (@sections) {
    print "\n", hr(), "\n";
    print $C{bold}, $C{cyan}, $s->{name}, $C{reset}, "\n";
    print hr(), "\n";

    my $kw = 0;
    for my $r (@{ $s->{rows} }) {
        my $k = length($r->[0] // '');
        $kw = $k if $k > $kw;
    }
    $kw = 30 if $kw > 30;

    for my $r (@{ $s->{rows} }) {
        my ($k, $v) = @$r;
        $k = '' unless defined $k;
        $v = '' unless defined $v;
        if (!length $v) {
            print $C{bold}, $k, $C{reset}, "\n";
        }
        elsif ($k =~ /^  /) {
            printf "%s%-*s%s %s\n", $C{dim}, $kw, $k, $C{reset}, $v;
        }
        else {
            printf "%s%-*s%s  %s\n", $C{dim}, $kw, $k, $C{reset}, $v;
        }
    }

    for my $n (@{ $s->{notes} }) {
        print $C{dim}, "note: $n", $C{reset}, "\n";
    }
    for my $w (@{ $s->{warnings} }) {
        print $C{yellow}, "WARN: $w", $C{reset}, "\n";
    }
}

print "\n", hr(), "\n";
print $C{bold}, $C{cyan}, "Health Summary", $C{reset}, "\n";
print hr(), "\n";
if (@all_warnings) {
    print $C{yellow}, sprintf("%d warning(s):", scalar @all_warnings), $C{reset}, "\n";
    print $C{yellow}, "  - $_\n", $C{reset} for @all_warnings;
}
else {
    print $C{green}, "No warnings: load, memory, disk and security checks look healthy.", $C{reset}, "\n";
}
print "\n";

exit 0;

# ---------------------------------------------------------------------------
sub usage {
    my ($code) = @_;
    my $me = $0;
    print <<"USAGE";
$me $VERSION - read-only system information collector for sysops.

Usage: perl $me [options]

Options:
  --json           Emit the same data as JSON (requires core JSON::PP).
  --only=STR       Only run sections whose tag contains STR
                   (identity, uptime, cpu, memory, disk, network,
                    processes, users, security).
  --timeout=SEC    Per-command timeout, 2..60 (default 10).
  --no-color       Disable ANSI colour (auto-disabled when not a TTY).
  -h, --help       Show this help.

Notes:
  * macOS (Darwin) and Linux are detected automatically; tools are chosen
    per platform. Unknown platforms fall back to generic probes.
  * All probes are read-only. No sudo, no writes, no config changes.

Environment (testing / diagnostics):
  SYSOP_FORCE_OS=linux|darwin   Force platform selection.
  SYSOP_PROC_ROOT=/path         Read procfs data from an alternate root.
USAGE
    exit $code;
}
