#!/usr/bin/env perl
#
# sysinfo.pl - read-only system information collector for sysops.
#
# Detects macOS (Darwin) or Linux and gathers:
#   host/OS, uptime, load, CPU, memory, swap, disks, network, top processes.
#
# Strictly read-only: no writes, no config changes, no process kills, no sudo.
# Requires only standard command-line tools present on both platforms
# (ps, df, uname, etc.) plus sw_vers (macOS) or /proc / lsb_release (Linux).
#
# Usage: perl sysinfo.pl [--section os|cpu|memory|disk|net|procs|all]
#
# Copyright-free example code. No warranty.

use strict;
use warnings;
use POSIX qw(uname strftime);

# ---------------------------------------------------------------- utilities

my $OS = detect_os();

# Run a command safely and return its stdout, or undef if unavailable/failed.
# Never uses a shell; args are passed as a list to avoid injection issues.
sub run_cmd {
    my ($cmd, @args) = @_;
    return undef unless defined $cmd;
    my $pid = open(my $fh, '-|');
    if (!defined $pid) { warn "fork failed: $!\n"; return undef; }
    if ($pid == 0) {                      # child
        open(STDERR, '>', '/dev/null');   # keep report clean
        exec($cmd, @args);
        exit 127;                         # exec failed: command not found
    }
    my $out = '';
    while (<$fh>) { $out .= $_; }
    close($fh);
    my $rc = $?;
    return undef if $rc == -1;            # signal
    return undef if ($rc >> 8) == 127;    # not found
    return $out;
}

sub trim { my $s = shift // ''; $s =~ s/^\s+|\s+$//g; return $s; }

# Return a defined string or a placeholder, so the report always renders.
sub or_dash { my $s = shift; return (defined $s and length trim($s)) ? trim($s) : '-'; }

sub line {
    my ($label, $value, $width) = @_;
    $width //= 24;
    printf("%-*s : %s\n", $width, $label, or_dash($value));
}

sub section {
    my $title = shift;
    print "\n", '=' x 62, "\n";
    print "$title\n";
    print '=' x 62, "\n";
}

sub slurp {   # read a whole file, return undef on failure (read-only)
    my $path = shift;
    open(my $fh, '<', $path) or return undef;
    local $/;
    my $data = <$fh>;
    close($fh);
    return $data;
}

sub detect_os {
    my ($sysname) = POSIX::uname();
    return 'macos'  if $sysname eq 'Darwin';
    return 'linux'  if $sysname eq 'Linux';
    return $sysname;    # unknown; best-effort elsewhere
}

# ---------------------------------------------------------------- sections

sub sec_host {
    section('HOST / OPERATING SYSTEM');
    my ($sys, $hostname, $release, $version, $machine) = POSIX::uname();
    line('Hostname', $hostname);
    line('Platform',  $OS eq 'macos' ? "macOS (Darwin)" : ucfirst($OS));
    line('Kernel',   "$sys $release");
    line('Arch',     $machine);

    if ($OS eq 'macos') {
        my $prod    = or_dash(run_cmd('/usr/bin/sw_vers', '-productName'));
        my $ver     = or_dash(run_cmd('/usr/bin/sw_vers', '-productVersion'));
        my $build   = or_dash(run_cmd('/usr/bin/sw_vers', '-buildVersion'));
        line('OS version', "$prod $ver (build $build)");
    }
    elsif ($OS eq 'linux') {
        my $pretty = linux_os_pretty();
        line('Distribution', $pretty);
        if (my $kr = trim(or_dash(slurp('/proc/sys/kernel/osrelease')))) {
            line('Kernel release', $kr);
        }
        # systemd systems expose boot mode (VM vs metal), purely informational
        if (my $vm = trim(or_dash(run_cmd('/usr/bin/systemd-detect-virt')))) {
            line('Virtualization', $vm eq 'none' ? 'bare metal' : $vm);
        }
    }

    my $uptime = uptime_text();
    line('Uptime', $uptime);

    if (my $u = run_cmd('/usr/bin/uptime')) {
        my $txt = or_dash($u);
        my ($load) = $txt =~ /load averages?: (.*)$/i;
        line('Load averages', $load) if $load;
    }
}

sub linux_os_pretty {
    if (my $r = slurp('/etc/os-release')) {
        my ($pretty) = $r =~ /^PRETTY_NAME="?([^"\n]+)"?/m;
        return $pretty if $pretty;
    }
    if (my $d = run_cmd('/usr/bin/lsb_release', '-d')) {
        (my $desc = trim($d)) =~ s/^Description:\s*//;
        return $desc;
    }
    return undef;
}

sub uptime_text {
    my $boot;
    if ($OS eq 'linux') {
        if (defined(my $b = slurp('/proc/stat')) and ($b) =~ /\bbtime (\d+)/) {
            $boot = $1;
        }
    }
    else {
        my $kern = run_cmd('/usr/sbin/sysctl', '-n', 'kern.boottime');
        if (defined $kern and $kern =~ /sec = (\d+)/) { $boot = $1; }
    }
    if ($boot) {
        my $secs = time() - $boot;
        $secs = 0 if $secs < 0;
        return sprintf('%d day%s, %02d:%02d:%02d',
            int($secs / 86400), ($secs >= 86400 ? 's' : ''),
            int(($secs % 86400) / 3600),
            int(($secs % 3600) / 60),
            $secs % 60);
    }
    return undef;
}

sub sec_cpu {
    section('CPU');
    my ($model, $cores, $phys, $threads, $mhz) = (undef) x 5;

    if ($OS eq 'linux') {
        if (defined(my $cpu = slurp('/proc/cpuinfo'))) {
            ($model) = $cpu =~ /^model name\s*:\s*(.+)$/m;
            ($mhz)   = $cpu =~ /^cpu MHz\s*:\s*([\d.]+)$/m;
            my %seen; my @ids;
            for my $ap ($cpu =~ /^processor\s*:\s*(\d+)$/mg) { push @ids, $ap unless $seen{$ap}++; }
            $threads = scalar @ids;
            my %phys_seen;
            for my $p ($cpu =~ /^physical id\s*:\s*(\d+)$/mg) { $phys_seen{$p} = 1; }
            $phys = scalar keys %phys_seen;
            my %core_seen;
            for my $c ($cpu =~ /^core id\s*:\s*(\d+)$/mg) { $core_seen{$c} = 1; }
            $cores = $phys ? scalar(keys %core_seen) * ($phys || 1) : undef;
        }
    }
    else {
        my $arch = run_cmd('/usr/bin/arch');
        my $n = run_cmd('/usr/sbin/sysctl', '-n', 'hw.ncpu');
        $threads = trim($n) if $n;
        $cores   = $threads;               # arm64 macs: p-cores/e-cores share the count
        my $name = run_cmd('/usr/sbin/sysctl', '-n', 'machdep.cpu.brand_string');
        $name    = run_cmd('/usr/sbin/sysctl', '-n', 'hw.model') unless defined $name;
        $model   = $name;
        my $f = run_cmd('/usr/sbin/sysctl', '-n', 'hw.cpufrequency');
        if ($f and trim($f) =~ /^\d+$/) { $mhz = sprintf('%.0f', trim($f) / 1_000_000); }
    }

    line('Model',    $model);
    line('Threads',  $threads);
    line('Physical cores', $cores) if $cores;
    line('Speed', defined $mhz ? "${mhz} MHz" : undef);

    # Current load per core, when measurable
    if ($OS eq 'linux' and defined(my $l = slurp('/proc/loadavg'))) {
        my ($one, $five, $fifteen) = $l =~ /^([\d.]+) ([\d.]+) ([\d.]+)/;
        if ($threads and $one) {
            line('Load per core', sprintf('%.2f (1m avg / %d cores)', $one / $threads, $threads));
        }
    }
}

sub sec_memory {
    section('MEMORY');
    if ($OS eq 'linux' and defined(my $m = slurp('/proc/meminfo'))) {
        my %k;
        while ($m =~ /^(\w+):\s+(\d+)\s*kB/mg) { $k{lc $1} = $2; }
        my $fmt_kb = sub { my $kb = shift;
            return sprintf('%.1f GB', $kb / 1048576) if $kb >= 1048576;
            return sprintf('%.1f MB', $kb / 1024);
        };
        my $total = $k{memtotal}        // 0;
        my $avail = $k{memavailable}    // ($k{memfree} // 0);
        my $used  = $total - $avail;
        my $pct   = $total ? sprintf('%.0f%%', 100 * $used / $total) : '-';
        line('Total RAM',     $fmt_kb->($total));
        line('Used RAM',      $fmt_kb->($total - $avail) . " ($pct)");
        line('Available RAM', $fmt_kb->($avail));
        my $swapt = $k{swaptotal} // 0;
        my $swapf = $k{swapfree}  // 0;
        if ($swapt) {
            my $spct = sprintf('%.0f%%', 100 * ($swapt - $swapf) / $swapt);
            line('Swap', $fmt_kb->($swapt - $swapf) . " used of " . $fmt_kb->($swapt) . " ($spct)");
        }
        else { line('Swap', 'none configured'); }
    }
    else {
        my $total = run_cmd('/usr/sbin/sysctl', '-n', 'hw.memsize');
        my $page_sz = run_cmd('/usr/sbin/sysctl', '-n', 'vm.pagesize');
        my $free    = run_cmd('/usr/sbin/sysctl', '-n', 'vm.page_free_count');
        my $spec    = run_cmd('/usr/sbin/sysctl', '-n', 'vm.pagespeculative_count');
        if ($total and $total =~ /^\d+$/) {
            my $tb = trim($total);
            line('Total RAM', $tb >= 1e9 ? sprintf('%.1f GB', $tb / 1e9) : sprintf('%.1f MB', $tb / 1e6));
            if ($free and $free =~ /^\d+$/ and $page_sz and $page_sz =~ /^\d+$/) {
                my $free_b = trim($free) * trim($page_sz);
                my $spec_b = ($spec and $spec =~ /^\d+$/) ? trim($spec) * trim($page_sz) : 0;
                my $avail = $free_b + $spec_b;
                my $used  = $tb - $avail;
                line('Free pages', sprintf('%.1f GB', $avail / 1e9));
                line('Approx. used', sprintf('%.1f GB (%.0f%%)', $used / 1e9, 100 * $used / $tb));
            }
        }
        # swapfiles (read-only listing)
        if (my $sf = run_cmd('/usr/sbin/sysctl', '-n', 'vm.swapusage')) {
            (my $su = or_dash($sf)) =~ s/^\s+|\s+$//g;
            line('Swap', $su);
        }
    }
}

sub sec_disk {
    section('DISK / FILESYSTEMS (local, human units)');
    my $df_out;
    if ($OS eq 'macos') {
        $df_out = run_cmd('/bin/df', '-H', '-l');
    }
    else {
        $df_out = run_cmd('/bin/df', '-h', '-l', '-x', 'tmpfs', '-x', 'devtmpfs');
        $df_out = run_cmd('/bin/df', '-h', '-l') unless defined $df_out;
    }
    return print "  (df unavailable)\n" unless defined $df_out;

    print "\n";
    my @lines = split /\n/, $df_out;
    for my $l (@lines) {
        my @f = split /\s+/, $l;
        next unless @f >= 6;
        next if $f[0] eq 'Filesystem';                     # header
        next unless $f[4] =~ /^(\d+)%$/;                   # want a % full column
        next if $f[5] =~ m{^/(dev|sys|proc|run)};          # pseudo filesystems
        next if $f[5] eq '/System/Volumes/VM';             # macOS VM volume
        # Highlight filesystems over 80% full with a marker.
        my $pct = $1;
        printf("  %-14s %-8s %8s of %-8s [%s] %3s%%\n",
            $f[0], $f[5], $f[2], $f[1], ($pct >= 80 ? '!!' : 'ok'), $pct);
    }
}

sub sec_network {
    section('NETWORK');
    if ($OS eq 'linux') {
        if (my $ip = run_cmd('/sbin/ip', '-brief', 'addr')) {
            print "\n";
            for my $l (split /\n/, $ip) {
                my @f = split /\s+/, trim($l);
                next unless @f >= 2;
                printf("  %-16s %-8s %s\n", $f[0], $f[1], join(' ', @f[2 .. $#f]));
            }
        }
        else {
            my $ifs = run_cmd('/sbin/ifconfig', '-a');
            print "\n", ($ifs // "(no network tool found)\n");
        }
    }
    else {
        if (my $ifs = run_cmd('/sbin/ifconfig', '-a')) {
            print "\n";
            for my $l (split /\n/, $ifs) {
                if ($l =~ /^(\S[^:]*):\s/) {
                    printf("  %-16s UP\n", $1);
                }
                elsif ($l =~ /^\s*inet (\S+)/) {
                    printf("  %-16s inet %s\n", '', $1);
                }
            }
        }
        else {
            print "  (ifconfig unavailable)\n";
        }
    }

    # Default route (useful for a sysop sanity check)
    my $gw;
    if ($OS eq 'linux') {
        $gw = run_cmd('/sbin/ip', 'route', 'show', 'default');
    }
    else {
        $gw = run_cmd('/sbin/route', '-n', 'get', 'default');
    }
    if ($gw and $gw =~ /via (\S+)/ ) { line("\nDefault gateway", $1); }
    elsif ($gw and $gw =~ /gateway:\s*(\S+)/) { line("\nDefault gateway", $1); }
}

sub sec_processes {
    section('TOP PROCESSES (by CPU)');
    my $ps_out;
    if ($OS eq 'macos') {
        $ps_out = run_cmd('/bin/ps', '-Ao', 'pcpu,pmem,rss,comm', '-r');
        # -r sorts by cpu on BSD ps; fall back to manual sort below anyway
    }
    else {
        $ps_out = run_cmd('/bin/ps', '-eo', 'pcpu,pmem,rss,comm', '--sort=-pcpu');
    }
    unless (defined $ps_out) {
        # generic POSIX fallback
        $ps_out = run_cmd('/bin/ps', '-Ao', 'pcpu,pmem,rss,comm');
    }
    return print "  (ps unavailable)\n" unless defined $ps_out;

    print "\n";
    my @lines = split /\n/, $ps_out;
    my @rows;
    for my $l (@lines) {
        my @f = split /\s+/, trim($l);
        next unless @f >= 4;
        next if $f[0] =~ /[^0-9.]/;        # skip header / non-numeric
        push @rows, [ $f[0] + 0, $f[1] + 0, $f[2] + 0, join(' ', @f[3 .. $#f]) ];
    }
    @rows = sort { $b->[0] <=> $a->[0] } @rows;
    printf("  %-6s %-6s %-10s %s\n", '%CPU', '%MEM', 'RSS(KB)', 'COMMAND');
    for my $r (@rows[0 .. ($#rows > 9 ? 9 : $#rows)]) {
        last unless $r;
        printf("  %-6.1f %-6.1f %-10d %s\n", $r->[0], $r->[1], $r->[2], $r->[3]);
    }

    my $count_out = run_cmd('/bin/ps', '-Axo', 'pid=');
    if (defined $count_out) {
        line("\nTotal processes", scalar(my @c = $count_out =~ /(\S+)/g));
    }
}

# ---------------------------------------------------------------- dispatch

my %sections = (
    os   => \&sec_host,
    cpu  => \&sec_cpu,
    mem  => \&sec_memory,
    disk => \&sec_disk,
    net  => \&sec_network,
    procs=> \&sec_processes,
);

my $want = 'all';
if (@ARGV) {
    my $arg = $ARGV[0];
    my $name;
    if ($arg =~ /^--section$/ and defined $ARGV[1]) { $name = $ARGV[1]; }
    else { $name = $arg; $name =~ s/^--//; }
    $name = lc $name;
    if ($name eq 'all' or exists $sections{$name}) { $want = $name; }
    else {
        die "Usage: $0 [--section os|cpu|mem|disk|net|procs|all]\n";
    }
}

print "System info report - ", strftime('%Y-%m-%d %H:%M:%S %Z', localtime), "\n";
print "Detected OS: ", ($OS eq 'macos' ? 'macOS (Darwin)' : $OS), "\n";

if ($want eq 'all') {
    $sections{os}->();
    $sections{cpu}->();
    $sections{mem}->();
    $sections{disk}->();
    $sections{net}->();
    $sections{procs}->();
}
else {
    $sections{$want}->();
}

print "\n";
print '-' x 62, "\n";
print "Report complete. (Read-only collection; no system changes made.)\n";