#!/usr/bin/env perl
#
# sysop_info.pl - read-only system information collector for sysop use.
#
# Works on both macOS (Darwin) and Linux. No sudo, no writes, no destructive
# probes: only read-only commands (uname, sysctl, df, ps, uptime, ip/ifconfig,
# who, top-level /proc reads).
#
# Usage:
#   perl sysop_info.pl                 # plain-text report to stdout
#   perl sysop_info.pl --json          # machine-readable JSON to stdout
#   perl sysop_info.pl --quiet         # suppress section banners (data only)
#   perl sysop_info.pl --sections disk,mem,load
#
# Exit code: 0 on success, 1 if the OS is unsupported.

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

my $IS_DARWIN = ($^O eq 'darwin') ? 1 : 0;
my $IS_LINUX  = ($^O eq 'linux')  ? 1 : 0;
unless ($IS_DARWIN || $IS_LINUX) {
    die "Unsupported OS: $^O (only Darwin and Linux are supported)\n";
}

my $NOW = strftime('%Y-%m-%d %H:%M:%S %Z', localtime());

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

# Run a shell command, return (exit_status, output). Output is captured
# from stdout; stderr is merged so we can report failures in the report.
sub run_cmd {
    my ($cmd) = @_;
    my $out = `($cmd) 2>&1`;
    my $rc  = $?;
    return ($rc == 0 ? 0 : $rc >> 8, $out);
}

# Run a command, return a list of trimmed lines (empty list on failure).
sub run_lines {
    my ($cmd) = @_;
    my ($rc, $out) = run_cmd($cmd);
    return () if $rc != 0;
    my @lines;
    for my $l (split /\n/, $out) {
        chomp $l;
        push @lines, $l if length $l;
    }
    return @lines;
}

# Run a command, return the first line (or undef).
sub run_first {
    my ($cmd) = @_;
    my @l = run_lines($cmd);
    return $l[0] if @l;
    return undef;
}

# Run a command, return the first line of the first match of a regex.
sub run_match {
    my ($cmd, $re) = @_;
    for my $l (run_lines($cmd)) {
        return $l if $l =~ $re;
    }
    return undef;
}

# Parse "key: value" style lines into a hash (first value wins).
sub parse_kv {
    my ($cmd) = @_;
    my %kv;
    for my $l (run_lines($cmd)) {
        if ($l =~ /^([A-Za-z0-9_ .]+?)\s*:\s*(.+)$/) {
            $kv{$1} = $2 unless exists $kv{$1};
        }
    }
    return %kv;
}

# Parse "key value" style lines (e.g. /proc/meminfo) into a hash.
sub parse_kv_space {
    my ($cmd) = @_;
    my %kv;
    for my $l (run_lines($cmd)) {
        if ($l =~ /^([A-Za-z0-9_]+)\s*:\s*(.+)$/) {
            $kv{$1} = $2 unless exists $kv{$1};
        }
    }
    return %kv;
}

# Humanize a byte count.
sub human_bytes {
    my ($n) = @_;
    return 'n/a' unless defined $n && $n >= 0;
    my @u = ('B', 'KB', 'MB', 'GB', 'TB', 'PB');
    while ($n >= 1024 && $u[0] ne 'PB') { $n /= 1024; shift @u; }
    return sprintf("%.1f %s", $n, $u[0]);
}

# Parse a "key: value" line into a number (e.g. "pages free: 123456").
sub parse_num {
    my ($s) = @_;
    return undef unless defined $s;
    my ($n) = $s =~ /(\d+(?:\.\d+)?)/;
    return $n + 0 if defined $n;
    return undef;
}

# ---------------------------------------------------------------------------
# Data collection
# ---------------------------------------------------------------------------

my %DATA;

# --- OS & kernel -----------------------------------------------------------
$DATA{os} = $IS_DARWIN ? 'macOS (Darwin)' : 'Linux';

my $uname = run_first('uname -a') // 'unknown';
$DATA{uname} = $uname;

if ($IS_DARWIN) {
    my %sv = parse_kv('sw_vers');
    $DATA{os_version} = sprintf("%s %s (build %s)",
        $sv{ProductVersion} // 'unknown',
        $sv{ProductName}    // 'unknown',
        $sv{BuildVersion}   // 'unknown');
    $DATA{kernel} = run_match('uname -r', qr/(\S+)/) // 'unknown';
    $DATA{kernel_full} = run_first('sysctl -n kern.osrelease') // $DATA{kernel};
    $DATA{arch} = run_first('uname -m') // 'unknown';
    $DATA{hostname} = run_first('hostname') // 'unknown';
} else {
    $DATA{os_version} = run_match('cat /etc/os-release', qr/^PRETTY_NAME="(.+)"$/)
        // run_match('cat /etc/os-release', qr/^NAME="(.+)"$/)
        // 'unknown';
    $DATA{kernel} = run_first('uname -r') // 'unknown';
    $DATA{kernel_full} = $DATA{kernel};
    $DATA{arch} = run_first('uname -m') // 'unknown';
    $DATA{hostname} = run_first('hostname') // 'unknown';
}

# --- Uptime & load ---------------------------------------------------------
my $uptime_line = run_first('uptime') // 'unknown';
$DATA{uptime} = $uptime_line;

if ($IS_DARWIN) {
    # macOS uptime: "up 1 day, 2:34, load average: 0.00, 0.00, 0.00"
    my $la = run_match('uptime', qr/load average:\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)/);
    $DATA{load} = $la ? sprintf("%.2f, %.2f, %.2f", $la->[1], $la->[2], $la->[3]) : 'n/a';
    my $up_secs = run_first('sysctl -n kern.boottime');
    if (defined $up_secs) {
        my $now_secs = time();
        my $up = $now_secs - $up_secs;
        my ($d, $h, $m, $s) = ($up, 0, 0, 0);
        $d = int($up / 86400); $up -= $d * 86400;
        $h = int($up / 3600);  $up -= $h * 3600;
        $m = int($up / 60);    $up -= $m * 60;
        $s = int($up);
        $DATA{uptime_hms} = sprintf("%d day(s), %02d:%02d:%02d", $d, $h, $m, $s);
    }
} else {
    # Linux uptime: "up 1 day, 2:34,  1 user,  load average: 0.00, 0.00, 0.00"
    my $la = run_match('uptime', qr/load average:\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)/);
    $DATA{load} = $la ? sprintf("%.2f, %.2f, %.2f", $la->[1], $la->[2], $la->[3]) : 'n/a';
    my $up_secs = run_first('cat /proc/uptime');
    if (defined $up_secs) {
        my $up = $up_secs + 0;
        my ($d, $h, $m, $s) = ($up, 0, 0, 0);
        $d = int($up / 86400); $up -= $d * 86400;
        $h = int($up / 3600);  $up -= $h * 3600;
        $m = int($up / 60);    $up -= $m * 60;
        $s = int($up);
        $DATA{uptime_hms} = sprintf("%d day(s), %02d:%02d:%02d", $d, $h, $m, $s);
    }
}

# --- CPU -------------------------------------------------------------------
if ($IS_DARWIN) {
    $DATA{cpu_model} = run_first('sysctl -n hw.model') // 'unknown';
    $DATA{cpu_cores} = run_first('sysctl -n hw.ncpu') // 'unknown';
    $DATA{cpu_vendor} = run_first('sysctl -n machdep.cpu.brand_string') // 'unknown';
} else {
    my $model = run_match('grep "model name" /proc/cpuinfo', qr/model name:\s*(.+)$/);
    $DATA{cpu_model} = $model // run_match('grep "processor" /proc/cpuinfo', qr/processor\s*:\s*(\d+)/) // 'unknown';
    $DATA{cpu_cores} = run_first('nproc') // run_match('grep -c processor /proc/cpuinfo', qr/^(\d+)$/);
    $DATA{cpu_vendor} = run_match('grep "vendor_id" /proc/cpuinfo', qr/vendor_id:\s*(.+)$/);
}

# --- Memory ----------------------------------------------------------------
if ($IS_DARWIN) {
    my $memsize = run_first('sysctl -n hw.memsize') // 0;
    my $pagesize = run_first('sysctl -n hw.pagesize') // 4096;
    my %vm = parse_kv('vm_stat');
    my $free = parse_num($vm{'pages free'});
    my $wired = parse_num($vm{'pages wired'});
    my $dirty = parse_num($vm{'pages dirty'});
    my $swapped_out = parse_num($vm{'pages swapped out'});
    my $total_pages = $memsize / $pagesize;
    my $free_bytes = ($free // 0) * $pagesize;
    my $wired_bytes = ($wired // 0) * $pagesize;
    my $swapped_bytes = ($swapped_out // 0) * $pagesize;
    my $used_bytes = $total_pages * $pagesize - $free_bytes;
    $DATA{mem_total} = human_bytes($memsize);
    $DATA{mem_free} = human_bytes($free_bytes);
    $DATA{mem_wired} = human_bytes($wired_bytes);
    $DATA{mem_used} = human_bytes($used_bytes);
    $DATA{mem_swapped_out} = human_bytes($swapped_bytes);
    my $pct_used = $total_pages > 0 ? sprintf("%.1f", 100 * $used_bytes / $memsize) : 'n/a';
    $DATA{mem_pct_used} = $pct_used;
} else {
    my %mi = parse_kv_space('cat /proc/meminfo');
    my $total = parse_num($mi{MemTotal});
    my $free = parse_num($mi{MemFree});
    my $buff = parse_num($mi{Buffers});
    my $cache = parse_num($mi{Cached});
    my $avail = parse_num($mi{MemAvailable});
    my $used = $total - ($avail // $free);
    my $swapped = parse_num($mi{SwapUsed});
    my $total_kb = $total;
    my $free_kb = $free;
    my $avail_kb = $avail;
    my $used_kb = $used;
    my $swapped_kb = $swapped;
    $DATA{mem_total} = human_bytes($total_kb * 1024);
    $DATA{mem_free} = human_bytes($free_kb * 1024);
    $DATA{mem_available} = human_bytes($avail_kb * 1024);
    $DATA{mem_used} = human_bytes($used_kb * 1024);
    $DATA{mem_swapped} = human_bytes($swapped_kb * 1024);
    $DATA{mem_pct_used} = $total_kb > 0 ? sprintf("%.1f", 100 * $used_kb / $total_kb) : 'n/a';
}

# --- Disk ------------------------------------------------------------------
my @disk;
my $df_out = run_first('df -h');
if (defined $df_out) {
    for my $l (split /\n/, $df_out) {
        chomp $l;
        next if $l =~ /^Filesystem/;
        next if $l =~ /^\s*$/;
        my @f = split /\s+/, $l;
        next unless @f >= 5;
        my ($fs, $dev, $size, $used, $avail, $usepct, $mount) = @f[0,1,2,3,4,5,6];
        push @disk, {
            fs => $fs,
            dev => $dev,
            size => $size,
            used => $used,
            avail => $avail,
            usepct => $usepct,
            mount => $mount,
        };
    }
}
$DATA{disk} = \@disk;

# --- Network ----------------------------------------------------------------
my @net;
if ($IS_DARWIN) {
    for my $l (run_lines('ifconfig -a')) {
        if ($l =~ /^(\w+)\s*:/) {
            my $iface = $1;
            my $inet = run_match("ifconfig $iface", qr/inet\s+([\d.]+)/);
            my $mtu = run_match("ifconfig $iface", qr/mtu\s+(\d+)/);
            my $status = run_match("ifconfig $iface", qr/[^:]*status:\s*(\w+)/);
            push @net, {
                iface => $iface,
                inet => $inet // 'n/a',
                mtu => $mtu // 'n/a',
                status => $status // 'n/a',
            };
        }
    }
    # Routes
    my $routes = run_first('netstat -rn');
    $DATA{routes} = $routes;
} else {
    for my $l (run_lines('ip -o link')) {
        if ($l =~ /^(\d+)\.\s+(\w+):/) {
            my $iface = $2;
            my $inet = run_match("ip -o -b addr show dev $iface", qr/inet\s+([\d.]+)/);
            my $mtu = run_match("ip link show $iface", qr/mtu\s+(\d+)/);
            my $state = run_match("ip link show $iface", qr/state\s+(\w+)/);
            push @net, {
                iface => $iface,
                inet => $inet // 'n/a',
                mtu => $mtu // 'n/a',
                state => $state // 'n/a',
            };
        }
    }
    $DATA{routes} = run_first('ip route');
}
$DATA{network} = \@net;

# --- Users ------------------------------------------------------------------
my $users = run_first('who') // 'n/a';
$DATA{users} = $users;

# --- Top processes ---------------------------------------------------------
my @procs;
if ($IS_DARWIN) {
    # BSD ps: -r sorts by CPU descending
    for my $l (run_lines('ps -eo pid,user,%cpu,%mem,etime,comm -r | head -n 11')) {
        next if $l =~ /^PID/;
        my @f = split /\s+/, $l;
        next unless @f >= 6;
        push @procs, {
            pid => $f[0],
            user => $f[1],
            cpu => $f[2],
            mem => $f[3],
            etime => $f[4],
            cmd => $f[5],
        };
    }
} else {
    for my $l (run_lines('ps -eo pid,user,%cpu,%mem,etime,comm --sort=-%cpu | head -n 11')) {
        next if $l =~ /^PID/;
        my @f = split /\s+/, $l;
        next unless @f >= 6;
        push @procs, {
            pid => $f[0],
            user => $f[1],
            cpu => $f[2],
            mem => $f[3],
            etime => $f[4],
            cmd => $f[5],
        };
    }
}
$DATA{procs} = \@procs;

# --- Summary / alerts ------------------------------------------------------
my @alerts;
for my $d (@{ $DATA{disk} // [] }) {
    my $pct = ($d->{usepct} // '') =~ /(\d+)/ ? $1 : 0;
    push @alerts, "Disk $d->{mount} at $pct% full" if $pct >= 90;
}
if (defined $DATA{mem_pct_used}) {
    my $pct = $DATA{mem_pct_used} =~ /(\d+(?:\.\d+)?)/ ? $1 : 0;
    push @alerts, "Memory at $pct% used" if $pct >= 90;
}
if (defined $DATA{load}) {
    my ($l1, $l2, $l3) = split /,/, $DATA{load};
    my $cores = $DATA{cpu_cores} + 0;
    if ($cores > 0 && $l1 >= $cores * 2) {
        push @alerts, "Load average ($l1) is high for $cores core(s)";
    }
}
$DATA{alerts} = \@alerts;

# ---------------------------------------------------------------------------
# Report rendering
# ---------------------------------------------------------------------------

my $QUIET = 0;
my $JSON  = 0;
my %SECTIONS_WANTED;

for my $arg (@ARGV) {
    if ($arg eq '--json') { $JSON = 1; next; }
    if ($arg eq '--quiet') { $QUIET = 1; next; }
    if ($arg =~ /^--sections=(.+)$/) {
        for my $s (split /,/, $1) {
            $SECTIONS_WANTED{lc $s} = 1;
        }
        next;
    }
}

sub section {
    my ($name, $title) = @_;
    my $key = lc $name;
    # If --sections was given, only emit requested sections.
    if (keys %SECTIONS_WANTED) {
        return 0 unless $SECTIONS_WANTED{$key};
    }
    return 1;
}

if ($JSON) {
    # Minimal JSON output (no external modules required).
    my $json = json_encode(\%DATA);
    print "$json\n";
    exit 0;
}

my $W = 72;
my $banner = '=' x $W;
my $sub    = '-' x $W;

print "$banner\n";
print " SYSOP SYSTEM INFORMATION REPORT\n";
print " Generated: $NOW\n";
print "$banner\n\n";

# --- Section: OS & kernel ---
if (section('os', 'OS & Kernel')) {
    print "OS:                $DATA{os}\n";
    print "Hostname:          $DATA{hostname}\n";
    print "Architecture:      $DATA{arch}\n";
    print "OS Version:        $DATA{os_version}\n";
    print "Kernel:            $DATA{kernel_full}\n";
    print "uname -a:          $DATA{uname}\n\n";
}

# --- Section: Uptime & load ---
if (section('uptime', 'Uptime & Load')) {
    print "Uptime:            $DATA{uptime}\n";
    if (defined $DATA{uptime_hms}) {
        print "Uptime (hms):      $DATA{uptime_hms}\n";
    }
    print "Load average:      $DATA{load}\n\n";
}

# --- Section: CPU ---
if (section('cpu', 'CPU')) {
    print "Model:             $DATA{cpu_model}\n";
    print "Cores:             $DATA{cpu_cores}\n";
    if (defined $DATA{cpu_vendor} && $DATA{cpu_vendor} ne 'unknown') {
        print "Vendor:            $DATA{cpu_vendor}\n";
    }
    print "\n";
}

# --- Section: Memory ---
if (section('mem', 'Memory')) {
    print "Total:             $DATA{mem_total}\n";
    print "Used:              $DATA{mem_used}\n";
    if (defined $DATA{mem_available}) {
        print "Available:         $DATA{mem_available}\n";
    }
    print "Free:              $DATA{mem_free}\n";
    if (defined $DATA{mem_swapped}) {
        print "Swapped:           $DATA{mem_swapped}\n";
    }
    if ($IS_DARWIN) {
        print "Wired:             $DATA{mem_wired}\n";
        if (defined $DATA{mem_swapped_out}) {
            print "Swapped out:       $DATA{mem_swapped_out}\n";
        }
    }
    print "Percent used:      $DATA{mem_pct_used}%\n\n";
}

# --- Section: Disk ---
if (section('disk', 'Disk')) {
    printf "%-20s %-12s %-10s %-10s %-10s %s\n", 'Filesystem', 'Size', 'Used', 'Avail', 'Use%', 'Mounted';
    for my $d (@{ $DATA{disk} // [] }) {
        printf "%-20s %-12s %-10s %-10s %-10s %s\n",
            $d->{fs}, $d->{size}, $d->{used}, $d->{avail}, $d->{usepct}, $d->{mount};
    }
    print "\n";
}

# --- Section: Network ---
if (section('network', 'Network')) {
    printf "%-12s %-15s %-8s %-8s %s\n", 'Interface', 'IPv4', 'MTU', 'State', 'Status';
    for my $n (@{ $DATA{network} // [] }) {
        printf "%-12s %-15s %-8s %-8s %s\n",
            $n->{iface}, $n->{inet}, $n->{mtu}, $n->{state} // $n->{status} // 'n/a',
            $n->{status} // $n->{state} // 'n/a';
    }
    if (defined $DATA{routes}) {
        print "\nRoutes:\n";
        print "  $_\n" for split /\n/, $DATA{routes};
    }
    print "\n";
}

# --- Section: Users ---
if (section('users', 'Logged-in Users')) {
    print "who:\n";
    print "  $_\n" for split /\n/, $DATA{users};
    print "\n";
}

# --- Section: Top processes ---
if (section('procs', 'Top Processes (by CPU)')) {
    printf "%-8s %-12s %-6s %-6s %-10s %s\n", 'PID', 'User', 'CPU%', 'MEM%', 'Elapsed', 'Command';
    for my $p (@{ $DATA{procs} // [] }) {
        printf "%-8s %-12s %-6s %-6s %-10s %s\n",
            $p->{pid}, $p->{user}, $p->{cpu}, $p->{mem}, $p->{etime}, $p->{cmd};
    }
    print "\n";
}

# --- Section: Alerts ---
if (section('alerts', 'Alerts')) {
    if (@{ $DATA{alerts} // [] }) {
        for my $a (@{ $DATA{alerts} }) {
            print "  ! $a\n";
        }
    } else {
        print "  No alerts.\n";
    }
    print "\n";
}

print "$banner\n";
print " End of report\n";
print "$banner\n";

exit 0;

# ---------------------------------------------------------------------------
# Minimal JSON encoder (no external modules)
# ---------------------------------------------------------------------------

sub json_escape {
    my ($s) = @_;
    $s =~ s/\\/\\\\/g;
    $s =~ s/"/\\"/g;
    $s =~ s/\n/\\n/g;
    $s =~ s/\r/\\r/g;
    $s =~ s/\t/\\t/g;
    return $s;
}

sub json_encode {
    my ($v) = @_;
    return 'null' if !defined $v;
    return '"'.json_escape($v).'"' if defined $v && ref($v) ne 'ARRAY' && ref($v) ne 'HASH';
    if (ref($v) eq 'ARRAY') {
        my @parts;
        for my $e (@$v) { push @parts, json_encode($e); }
        return '[' . join(',', @parts) . ']';
    }
    if (ref($v) eq 'HASH') {
        my @parts;
        for my $k (sort keys %$v) {
            push @parts, '"' . json_escape($k) . '" : ' . json_encode($v->{$k});
        }
        return '{' . join(', ', @parts) . '}';
    }
    return 'null';
}
