<?php
/**
 * NetStats Node Agent for netstats.ir
 * Upload to your public web root. Set the constants below after admin approval.
 *
 * Endpoints:
 *   ?action=health
 *   ?action=run   (POST JSON, Authorization: Bearer <NODE_TOKEN>)
 */
declare(strict_types=1);

// ===================== CONFIG =====================
const HUB_URL = 'https://netstats.ir';
const NODE_TOKEN = '';          // paste after approval
const HUB_HMAC_SECRET = '';     // paste after approval
const NODE_NAME = 'node';
const ALLOW_PRIVATE_TARGETS = false;
const AGENT_VERSION = '1.2.0';
const MAX_BODY = 65536;
const DEFAULT_TIMEOUT = 10;
const HTTP_MAX_DOWNLOAD = 16384;
const HTTP_MAX_TIMEOUT = 8;
// ==================================================

header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: no-referrer');

$action = strtolower((string) ($_GET['action'] ?? 'health'));

if ($action === 'health') {
    ns_agent_json([
        'ok' => 1,
        'version' => AGENT_VERSION,
        'name' => NODE_NAME,
    ]);
}

if ($action !== 'run') {
    ns_agent_json(['ok' => 0, 'error' => 'Unknown action'], 404);
}

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    ns_agent_json(['ok' => 0, 'error' => 'POST required'], 405);
}

if (NODE_TOKEN === '' || HUB_HMAC_SECRET === '') {
    ns_agent_json(['ok' => 0, 'error' => 'Agent not configured'], 503);
}

$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '');
if (!preg_match('/^Bearer\s+(\S+)$/i', $auth, $m) || !hash_equals(NODE_TOKEN, $m[1])) {
    ns_agent_json(['ok' => 0, 'error' => 'Unauthorized'], 401);
}

$len = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($len > MAX_BODY) {
    ns_agent_json(['ok' => 0, 'error' => 'Payload too large'], 413);
}
$raw = file_get_contents('php://input', false, null, 0, MAX_BODY + 1);
if ($raw === false || strlen($raw) > MAX_BODY) {
    ns_agent_json(['ok' => 0, 'error' => 'Payload too large'], 413);
}
$job = json_decode($raw, true);
if (!is_array($job)) {
    ns_agent_json(['ok' => 0, 'error' => 'Invalid JSON'], 400);
}

$requestId = (string) ($job['request_id'] ?? '');
$nodeId = (string) ($job['node_id'] ?? '');
$type = strtolower((string) ($job['type'] ?? ''));
$host = (string) ($job['host'] ?? '');
$timeout = (int) ($job['timeout'] ?? DEFAULT_TIMEOUT);
$exp = (int) ($job['exp'] ?? 0);
$nonce = (string) ($job['nonce'] ?? '');
$sig = (string) ($job['sig'] ?? '');
$callback = (string) ($job['callback'] ?? '');
// Private targets require an explicit operator opt-in on this agent.
// A compromised or misconfigured hub cannot force allow_private via the job flag alone.
$allowPrivate = ALLOW_PRIVATE_TARGETS;

if ($exp < time()) {
    ns_agent_json(['ok' => 0, 'error' => 'Job expired'], 403);
}
if (!preg_match('/^[a-zA-Z0-9_-]{8,64}$/', $requestId) || $nonce === '' || $sig === '') {
    ns_agent_json(['ok' => 0, 'error' => 'Bad job'], 400);
}

$signFields = [
    'request_id' => $requestId,
    'node_id' => $nodeId,
    'type' => $type,
    'host' => $host,
    'timeout' => $timeout,
    'exp' => $exp,
    'nonce' => $nonce,
    'allow_private' => !empty($job['allow_private']) ? 1 : 0,
];
if (!ns_agent_hmac_verify($signFields, $sig, HUB_HMAC_SECRET)) {
    ns_agent_json(['ok' => 0, 'error' => 'Bad signature'], 403);
}

if (!in_array($type, ['ping', 'http', 'tcp', 'dns', 'udp', 'traceroute', 'traceroute_tcp', 'mtr'], true)) {
    ns_agent_json(['ok' => 0, 'error' => 'Unsupported type'], 400);
}

$maxTimeout = in_array($type, ['traceroute', 'traceroute_tcp', 'mtr'], true) ? 45 : 20;
$timeout = max(2, min($maxTimeout, $timeout));

try {
    $target = ns_agent_parse_target($host, $type, $allowPrivate);
    $result = ns_agent_run_check($type, $target, $timeout);
} catch (Throwable $e) {
    $result = ['error' => $e->getMessage()];
}

// Optional callback to hub (best-effort); also return synchronously
if ($callback !== '' && preg_match('#^https://#i', $callback)) {
    ns_agent_callback($callback, $requestId, $nodeId, $result);
}

ns_agent_json(['ok' => 1, 'result' => $result]);

// ----------------- helpers -----------------

function ns_agent_json(array $data, int $status = 200): void
{
    http_response_code($status);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data, JSON_UNESCAPED_SLASHES);
    exit;
}

function ns_agent_hmac_canonical(array $payload): string
{
    ksort($payload);
    $parts = [];
    foreach ($payload as $k => $v) {
        if (is_bool($v)) {
            $v = $v ? '1' : '0';
        }
        $parts[] = $k . '=' . (string) $v;
    }
    return implode('&', $parts);
}

function ns_agent_hmac_verify(array $payload, string $signature, string $secret): bool
{
    $expected = hash_hmac('sha256', ns_agent_hmac_canonical($payload), $secret);
    return hash_equals($expected, $signature);
}

function ns_agent_normalize_ip(string $ip): ?string
{
    $bin = @inet_pton(trim($ip, '[]'));
    if ($bin === false) {
        return null;
    }
    $out = @inet_ntop($bin);
    return $out === false ? null : $out;
}

function ns_agent_is_ipv6(string $ip): bool
{
    return (bool) filter_var(trim($ip, '[]'), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
}

function ns_agent_is_ipv4(string $ip): bool
{
    return (bool) filter_var(trim($ip, '[]'), FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
}

function ns_agent_host_for_url(string $host): string
{
    $host = trim($host, '[]');
    return ns_agent_is_ipv6($host) ? '[' . $host . ']' : $host;
}

function ns_agent_host_for_socket(string $ip): string
{
    $ip = trim($ip, '[]');
    return ns_agent_is_ipv6($ip) ? '[' . $ip . ']' : $ip;
}

function ns_agent_ip_in_cidr(string $ip, string $cidr): bool
{
    $ipNorm = ns_agent_normalize_ip($ip);
    if ($ipNorm === null) {
        return false;
    }
    if (!str_contains($cidr, '/')) {
        $entry = ns_agent_normalize_ip($cidr);
        return $entry !== null && $entry === $ipNorm;
    }
    [$subnet, $mask] = array_pad(explode('/', $cidr, 2), 2, '');
    $mask = (int) $mask;
    $ipBin = @inet_pton($ipNorm);
    $subnetBin = @inet_pton(trim($subnet, '[]'));
    if ($ipBin === false || $subnetBin === false || strlen($ipBin) !== strlen($subnetBin)) {
        return false;
    }
    $maxBits = strlen($ipBin) * 8;
    if ($mask < 0 || $mask > $maxBits) {
        return false;
    }
    $bytes = intdiv($mask, 8);
    $bits = $mask % 8;
    if ($bytes > 0 && substr($ipBin, 0, $bytes) !== substr($subnetBin, 0, $bytes)) {
        return false;
    }
    if ($bits === 0) {
        return true;
    }
    $maskByte = (~((1 << (8 - $bits)) - 1)) & 0xFF;
    return (ord($ipBin[$bytes]) & $maskByte) === (ord($subnetBin[$bytes]) & $maskByte);
}

function ns_agent_mapped_ipv4(string $ip): ?string
{
    $bin = @inet_pton(trim($ip, '[]'));
    if ($bin === false || strlen($bin) !== 16) {
        return null;
    }
    if (substr($bin, 0, 10) === str_repeat("\0", 10) && substr($bin, 10, 2) === "\xff\xff") {
        return inet_ntop(substr($bin, 12)) ?: null;
    }
    return null;
}

function ns_agent_is_blocked_ip(string $ip, bool $allowPrivate): bool
{
    if ($allowPrivate) {
        return false;
    }
    $norm = ns_agent_normalize_ip($ip);
    if ($norm === null) {
        return true;
    }
    if (ns_agent_is_ipv4($norm)) {
        $long = ip2long($norm);
        if ($long === false) {
            return true;
        }
        $ranges = [
            ['0.0.0.0', '0.255.255.255'],
            ['10.0.0.0', '10.255.255.255'],
            ['100.64.0.0', '100.127.255.255'],
            ['127.0.0.0', '127.255.255.255'],
            ['169.254.0.0', '169.254.255.255'],
            ['172.16.0.0', '172.31.255.255'],
            ['192.0.0.0', '192.0.0.255'],
            ['192.168.0.0', '192.168.255.255'],
            ['198.18.0.0', '198.19.255.255'],
            ['224.0.0.0', '255.255.255.255'],
        ];
        foreach ($ranges as [$a, $b]) {
            if ($long >= ip2long($a) && $long <= ip2long($b)) {
                return true;
            }
        }
        return false;
    }
    $mapped = ns_agent_mapped_ipv4($norm);
    if ($mapped !== null) {
        return ns_agent_is_blocked_ip($mapped, $allowPrivate);
    }
    foreach (['::1/128', '::/128', '100::/64', '2001:db8::/32', 'fc00::/7', 'fe80::/10', 'ff00::/8'] as $cidr) {
        if (ns_agent_ip_in_cidr($norm, $cidr)) {
            return true;
        }
    }
    return false;
}

function ns_agent_sort_ips(array $ips): array
{
    $v6 = [];
    $v4 = [];
    foreach ($ips as $ip) {
        $n = ns_agent_normalize_ip((string) $ip);
        if ($n === null) {
            continue;
        }
        if (ns_agent_is_ipv6($n)) {
            $v6[] = $n;
        } else {
            $v4[] = $n;
        }
    }
    return array_values(array_unique(array_merge($v6, $v4)));
}

function ns_agent_resolve(string $host, bool $allowPrivate): array
{
    $host = trim($host, '[]');
    if (filter_var($host, FILTER_VALIDATE_IP)) {
        $norm = ns_agent_normalize_ip($host);
        if ($norm === null || ns_agent_is_blocked_ip($norm, $allowPrivate)) {
            throw new InvalidArgumentException('Target IP not allowed');
        }
        return [$norm];
    }
    $ips = [];
    $recs = @dns_get_record($host, DNS_A + DNS_AAAA);
    if (is_array($recs)) {
        foreach ($recs as $r) {
            if (!empty($r['ipv6'])) {
                $ips[] = $r['ipv6'];
            }
            if (!empty($r['ip'])) {
                $ips[] = $r['ip'];
            }
        }
    }
    if (!$ips) {
        $aaaa = @dns_get_record($host, DNS_AAAA);
        if (is_array($aaaa)) {
            foreach ($aaaa as $r) {
                if (!empty($r['ipv6'])) {
                    $ips[] = $r['ipv6'];
                }
            }
        }
    }
    if (!$ips) {
        $a = @gethostbynamel($host);
        if (is_array($a)) {
            $ips = $a;
        }
    }
    $public = [];
    foreach ($ips as $ip) {
        $norm = ns_agent_normalize_ip((string) $ip);
        if ($norm === null || ns_agent_is_blocked_ip($norm, $allowPrivate)) {
            continue;
        }
        $public[] = $norm;
    }
    $public = ns_agent_sort_ips($public);
    if (!$public) {
        throw new InvalidArgumentException('Host could not be resolved');
    }
    return $public;
}

function ns_agent_parse_target(string $raw, string $type, bool $allowPrivate): array
{
    $raw = trim($raw);
    if ($raw === '' || strlen($raw) > 512 || preg_match('/\s/', $raw) || str_contains($raw, '@')) {
        throw new InvalidArgumentException('Invalid host');
    }
    $scheme = null;
    $host = $raw;
    $port = null;
    $path = '/';

    if (preg_match('#^(https?)://#i', $raw, $m)) {
        $scheme = strtolower($m[1]);
        $parts = parse_url($raw);
        if ($parts === false || empty($parts['host']) || !empty($parts['user'])) {
            throw new InvalidArgumentException('Invalid URL');
        }
        $host = (string) $parts['host'];
        $port = isset($parts['port']) ? (int) $parts['port'] : ($scheme === 'https' ? 443 : 80);
        $path = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : '');
    } elseif (preg_match('/^\[([^\]]+)\](?::(\d+))?$/', $raw, $m)) {
        $host = $m[1];
        $port = isset($m[2]) ? (int) $m[2] : null;
        if (!ns_agent_is_ipv6($host)) {
            throw new InvalidArgumentException('Invalid IPv6 address');
        }
    } elseif (filter_var($raw, FILTER_VALIDATE_IP)) {
        $host = $raw;
    } elseif (preg_match('/^(\d{1,3}(?:\.\d{1,3}){3}):(\d+)$/', $raw, $m)) {
        $host = $m[1];
        $port = (int) $m[2];
    } elseif (preg_match('/^([^:\/\[\]]+):(\d+)$/', $raw, $m)) {
        $host = $m[1];
        $port = (int) $m[2];
    }

    $host = trim($host, '[]');
    if (ns_agent_is_ipv4($host) || ns_agent_is_ipv6($host)) {
        $norm = ns_agent_normalize_ip($host);
        if ($norm === null) {
            throw new InvalidArgumentException('Invalid IP');
        }
        $host = $norm;
    } else {
        $host = strtolower($host);
    }

    if (in_array($host, ['localhost', 'ip6-localhost', 'ip6-loopback', 'metadata.google.internal', 'metadata'], true)) {
        throw new InvalidArgumentException('Host not allowed');
    }
    if ($port !== null && ($port < 1 || $port > 65535)) {
        throw new InvalidArgumentException('Invalid port');
    }
    if ($type === 'http') {
        $scheme = $scheme ?? 'http';
        $port = $port ?? ($scheme === 'https' ? 443 : 80);
    } elseif ($type === 'tcp' || $type === 'traceroute_tcp') {
        $port = $port ?? 80;
    } elseif ($type === 'udp') {
        $port = $port ?? 53;
    }

    $ips = ns_agent_resolve($host, $allowPrivate);
    return compact('raw', 'host', 'port', 'scheme', 'path', 'ips');
}

function ns_agent_run_check(string $type, array $target, int $timeout)
{
    return match ($type) {
        'ping' => ns_agent_ping($target, $timeout),
        'http' => ns_agent_http($target, $timeout),
        'tcp' => ns_agent_tcp($target, $timeout),
        'dns' => ns_agent_dns($target),
        'udp' => ns_agent_udp($target, $timeout),
        'traceroute' => ns_agent_traceroute($target, $timeout),
        'traceroute_tcp' => ns_agent_traceroute_tcp($target, $timeout),
        'mtr' => ns_agent_mtr($target, $timeout),
        default => ['error' => 'Unsupported'],
    };
}

function ns_agent_ping(array $target, int $timeout): array
{
    $ip = $target['ips'][0];
    $count = 4;
    $isWin = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
    $isV6 = ns_agent_is_ipv6($ip);

    if ($isWin) {
        $args = $isV6
            ? ['ping', '-6', '-n', (string) $count, '-w', (string) ($timeout * 1000), $ip]
            : ['ping', '-n', (string) $count, '-w', (string) ($timeout * 1000), $ip];
        $out = ns_agent_proc($args, $timeout + 2);
    } else {
        $args = $isV6
            ? ['ping', '-6', '-c', (string) $count, '-W', (string) $timeout, $ip]
            : ['ping', '-c', (string) $count, '-W', (string) $timeout, $ip];
        $out = ns_agent_proc($args, $timeout + 2);
        if ($isV6 && !$out['ok']) {
            $out = ns_agent_proc(['ping6', '-c', (string) $count, '-W', (string) $timeout, $ip], $timeout + 2);
        }
    }
    $samples = [];
    if ($out['ok']) {
        foreach (preg_split('/\r\n|\n|\r/', $out['stdout']) as $line) {
            if (preg_match('/time[=<]([\d.]+)\s*ms/i', $line, $m)) {
                $samples[] = ['OK', ((float) $m[1]) / 1000.0, $ip];
            } elseif (preg_match('/timed out|100% packet loss|Destination Host Unreachable|Network is unreachable/i', $line)) {
                $samples[] = ['TIMEOUT', (float) $timeout];
            }
        }
    }

    if (!$samples) {
        $t0 = microtime(true);
        $errno = 0;
        $errstr = '';
        $fp = @fsockopen(ns_agent_host_for_socket($ip), 80, $errno, $errstr, min(3, $timeout));
        $dt = microtime(true) - $t0;
        if ($fp) {
            fclose($fp);
            $samples[] = ['OK', round($dt, 4), $ip];
        } else {
            $samples[] = ['TIMEOUT', round($dt, 4)];
        }
    }

    while (count($samples) < $count) {
        $samples[] = ['TIMEOUT', (float) $timeout];
    }
    return [array_slice($samples, 0, $count)];
}

function ns_agent_http(array $target, int $timeout): array
{
    $scheme = $target['scheme'] ?? 'http';
    $host = $target['host'];
    $url = $scheme . '://' . ns_agent_host_for_url($host);
    if (!empty($target['port']) && !(($scheme === 'http' && $target['port'] === 80) || ($scheme === 'https' && $target['port'] === 443))) {
        $url .= ':' . $target['port'];
    }
    $url .= $target['path'] ?? '/';

    $ip = $target['ips'][0];
    if (ns_agent_is_blocked_ip($ip, ALLOW_PRIVATE_TARGETS)) {
        return [[0, 0.0, 'Blocked address', null, null]];
    }
    $port = (int) ($target['port'] ?: ($scheme === 'https' ? 443 : 80));
    $resolveIp = ns_agent_is_ipv6($ip) ? '[' . $ip . ']' : $ip;
    $wall = max(2, min(HTTP_MAX_TIMEOUT, $timeout));

    // Never follow redirects: Location can point at loopback/RFC1918/metadata and
    // CURLOPT_RESOLVE only pins the first hop. Report 3xx without fetching the next URL.
    $downloaded = 0;
    $maxBytes = HTTP_MAX_DOWNLOAD;
    $opts = [
        CURLOPT_RETURNTRANSFER => false,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_MAXREDIRS => 0,
        CURLOPT_TIMEOUT => $wall,
        CURLOPT_CONNECTTIMEOUT => min(3, $wall),
        CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
        CURLOPT_REDIR_PROTOCOLS => 0,
        CURLOPT_USERAGENT => 'NetStats-Node/' . AGENT_VERSION,
        CURLOPT_HEADER => true,
        CURLOPT_NOBODY => false,
        CURLOPT_WRITEFUNCTION => static function ($ch, string $data) use (&$downloaded, $maxBytes): int {
            $downloaded += strlen($data);
            if ($downloaded > $maxBytes) {
                return 0; // abort oversized body (anti amplification)
            }
            return strlen($data);
        },
    ];
    if (defined('CURLOPT_MAXFILESIZE')) {
        $opts[CURLOPT_MAXFILESIZE] = $maxBytes;
    }
    if (defined('CURL_IPRESOLVE_V6')) {
        $opts[CURLOPT_IPRESOLVE] = ns_agent_is_ipv6($ip) ? CURL_IPRESOLVE_V6 : CURL_IPRESOLVE_V4;
    }
    if (!filter_var($host, FILTER_VALIDATE_IP)) {
        $opts[CURLOPT_RESOLVE] = [$host . ':' . $port . ':' . $resolveIp];
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, $opts);

    $okExec = curl_exec($ch);
    $errno = curl_errno($ch);
    $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $total = (float) curl_getinfo($ch, CURLINFO_TOTAL_TIME);
    $primary = (string) curl_getinfo($ch, CURLINFO_PRIMARY_IP);
    curl_close($ch);

    if ($primary !== '' && ns_agent_is_blocked_ip($primary, ALLOW_PRIVATE_TARGETS)) {
        return [[0, round($total, 4), 'Blocked address', null, null]];
    }
    if ($errno === CURLE_WRITE_ERROR || $errno === 23 || $downloaded > $maxBytes) {
        // Aborted by size cap after headers — still report status if we got one
        if ($code > 0) {
            $ok = $code >= 200 && $code < 400 ? 1 : 0;
            $text = ($code >= 300 && $code < 400) ? 'Redirect' : ($ok ? 'OK' : 'Error');
            return [[$ok, round($total, 4), $text, (string) $code, $ip]];
        }
        return [[0, round($total, 4), 'Response too large', null, null]];
    }
    if ($okExec === false || $errno) {
        return [[0, round($total, 4), ns_agent_public_curl_error($errno), null, null]];
    }

    $ok = $code >= 200 && $code < 400 ? 1 : 0;
    $text = ($code >= 300 && $code < 400) ? 'Redirect' : ($ok ? 'OK' : 'Error');
    // Always report the pre-validated public target IP, never a post-redirect peer.
    return [[$ok, round($total, 4), $text, (string) $code, $ip]];
}

function ns_agent_public_curl_error(int $errno): string
{
    return match ($errno) {
        6 => 'DNS failed',           // CURLE_COULDNT_RESOLVE_HOST
        7 => 'Connection failed',    // CURLE_COULDNT_CONNECT
        28 => 'Timeout',             // CURLE_OPERATION_TIMEDOUT
        35, 51, 53, 54, 58, 59, 60, 64, 66, 77, 80, 82, 83, 90, 91 => 'TLS failed',
        23 => 'Response too large',  // CURLE_WRITE_ERROR
        47 => 'Too many redirects',
        default => 'Request failed',
    };
}

function ns_agent_tcp(array $target, int $timeout): array
{
    $ip = $target['ips'][0];
    $port = (int) $target['port'];
    $t0 = microtime(true);
    $errno = 0;
    $errstr = '';
    $fp = @fsockopen(ns_agent_host_for_socket($ip), $port, $errno, $errstr, $timeout);
    $dt = round(microtime(true) - $t0, 4);
    if ($fp) {
        fclose($fp);
        return ['time' => $dt, 'address' => $ip];
    }
    return ['error' => 'Connection failed'];
}

function ns_agent_udp(array $target, int $timeout): array
{
    $ip = $target['ips'][0];
    $port = (int) $target['port'];
    $t0 = microtime(true);
    $sock = @fsockopen('udp://' . ns_agent_host_for_socket($ip), $port, $errno, $errstr, $timeout);
    if (!$sock) {
        return ['ok' => 0, 'error' => 'UDP open failed'];
    }
    stream_set_timeout($sock, $timeout);
    @fwrite($sock, "\x00");
    $dt = round(microtime(true) - $t0, 4);
    fclose($sock);
    return ['ok' => 1, 'time' => $dt, 'address' => $ip];
}

function ns_agent_dns(array $target): array
{
    $host = $target['host'];
    $a = [];
    $aaaa = [];
    $ttl = null;
    $recs = @dns_get_record($host, DNS_A + DNS_AAAA);
    if (is_array($recs)) {
        foreach ($recs as $r) {
            if (($r['type'] ?? '') === 'A' && !empty($r['ip'])) {
                $a[] = $r['ip'];
                if (isset($r['ttl'])) {
                    $ttl = (int) $r['ttl'];
                }
            }
            if (($r['type'] ?? '') === 'AAAA' && !empty($r['ipv6'])) {
                $aaaa[] = $r['ipv6'];
                if (isset($r['ttl'])) {
                    $ttl = (int) $r['ttl'];
                }
            }
        }
    }
    if (!$a && !$aaaa) {
        $a = @gethostbynamel($host) ?: [];
    }
    return ['A' => array_values(array_unique($a)), 'AAAA' => array_values(array_unique($aaaa)), 'TTL' => $ttl];
}

function ns_agent_path_max_hops(): int
{
    return 20;
}

function ns_agent_traceroute(array $target, int $timeout): array
{
    $ip = $target['ips'][0];
    $maxHops = ns_agent_path_max_hops();
    $isWin = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
    $isV6 = ns_agent_is_ipv6($ip);
    $wall = max(8, min(45, $timeout));

    if ($isWin) {
        $args = $isV6
            ? ['tracert', '-6', '-d', '-h', (string) $maxHops, '-w', '1000', $ip]
            : ['tracert', '-d', '-h', (string) $maxHops, '-w', '1000', $ip];
        $out = ns_agent_proc($args, $wall);
        $hops = ns_agent_parse_tracert($out['stdout'] . "\n" . $out['stderr']);
        $method = 'tracert';
    } else {
        $attempts = [];
        if ($isV6) {
            $attempts[] = ['traceroute', '-6', '-n', '-q', '1', '-w', '1', '-m', (string) $maxHops, $ip];
            $attempts[] = ['traceroute6', '-n', '-q', '1', '-w', '1', '-m', (string) $maxHops, $ip];
        } else {
            $attempts[] = ['traceroute', '-n', '-q', '1', '-w', '1', '-m', (string) $maxHops, $ip];
            $attempts[] = ['tracepath', '-n', '-m', (string) $maxHops, $ip];
        }

        $out = ['ok' => false, 'stdout' => '', 'stderr' => ''];
        $method = 'traceroute';
        $hops = [];
        foreach ($attempts as $args) {
            $out = ns_agent_proc($args, $wall);
            $text = $out['stdout'] . "\n" . $out['stderr'];
            if (str_contains($text, 'not found') || str_contains($text, 'No such file')) {
                continue;
            }
            $method = $args[0];
            $hops = str_starts_with($args[0], 'tracepath')
                ? ns_agent_parse_tracepath($text)
                : ns_agent_parse_traceroute($text);
            if ($hops) {
                break;
            }
        }
    }

    if (!$hops) {
        return [
            'error' => 'traceroute unavailable or produced no hops (install traceroute/tracert on the node)',
            'target' => $ip,
            'method' => $method ?? 'traceroute',
        ];
    }

    return [
        'target' => $ip,
        'method' => $method,
        'hops' => $hops,
    ];
}

function ns_agent_traceroute_tcp(array $target, int $timeout): array
{
    $ip = $target['ips'][0];
    $port = (int) ($target['port'] ?? 80);
    if ($port < 1 || $port > 65535) {
        $port = 80;
    }
    $maxHops = ns_agent_path_max_hops();
    $isWin = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
    $isV6 = ns_agent_is_ipv6($ip);
    $wall = max(8, min(45, $timeout));

    if ($isWin) {
        return [
            'error' => 'TCP traceroute needs Linux traceroute -T or tcptraceroute (not available on Windows nodes)',
            'target' => $ip,
            'port' => $port,
            'method' => 'traceroute_tcp',
        ];
    }

    $attempts = [];
    if ($isV6) {
        $attempts[] = ['traceroute', '-6', '-n', '-T', '-p', (string) $port, '-q', '1', '-w', '1', '-m', (string) $maxHops, $ip];
        $attempts[] = ['traceroute6', '-n', '-T', '-p', (string) $port, '-q', '1', '-w', '1', '-m', (string) $maxHops, $ip];
        $attempts[] = ['tcptraceroute', '-n', '-m', (string) $maxHops, $ip, (string) $port];
        $attempts[] = ['mtr', '-6', '-n', '-T', '-P', (string) $port, '-r', '-c', '1', '-m', (string) $maxHops, '--timeout', '1', $ip];
    } else {
        $attempts[] = ['traceroute', '-n', '-T', '-p', (string) $port, '-q', '1', '-w', '1', '-m', (string) $maxHops, $ip];
        $attempts[] = ['tcptraceroute', '-n', '-m', (string) $maxHops, $ip, (string) $port];
        $attempts[] = ['mtr', '-n', '-T', '-P', (string) $port, '-r', '-c', '1', '-m', (string) $maxHops, '--timeout', '1', $ip];
    }

    $method = 'traceroute_tcp';
    $hops = [];
    $lastText = '';
    foreach ($attempts as $args) {
        $out = ns_agent_proc($args, $wall);
        $text = $out['stdout'] . "\n" . $out['stderr'];
        $lastText = $text;
        if (preg_match('/not found|No such file|not recognized/i', $text) && trim($out['stdout']) === '') {
            continue;
        }
        if (preg_match('/must be root|Permission denied|Operation not permitted|you must be root/i', $text) && trim($out['stdout']) === '') {
            continue;
        }
        $method = $args[0];
        if ($args[0] === 'mtr') {
            $hops = ns_agent_parse_mtr($text);
        } else {
            $hops = ns_agent_parse_traceroute($text);
        }
        if ($hops) {
            break;
        }
    }

    if (!$hops) {
        $hint = 'install traceroute with TCP support (traceroute -T) or tcptraceroute; may require root/CAP_NET_RAW';
        if (preg_match('/must be root|Permission denied|Operation not permitted|you must be root/i', $lastText)) {
            $hint = 'TCP traceroute requires root or CAP_NET_RAW on this node';
        }
        return [
            'error' => 'TCP traceroute unavailable or produced no hops (' . $hint . ')',
            'target' => $ip,
            'port' => $port,
            'method' => 'traceroute_tcp',
        ];
    }

    $note = null;
    if ($method === 'mtr') {
        $note = 'mtr TCP mode used';
        $method = 'mtr-tcp';
    } elseif ($method === 'tcptraceroute') {
        $method = 'tcptraceroute';
    } else {
        $method = 'traceroute-tcp';
    }

    $out = [
        'target' => $ip,
        'port' => $port,
        'method' => $method,
        'hops' => $hops,
    ];
    if ($note !== null) {
        $out['note'] = $note;
    }
    return $out;
}

function ns_agent_mtr(array $target, int $timeout): array
{
    $ip = $target['ips'][0];
    $maxHops = ns_agent_path_max_hops();
    $cycles = 3; // fast report; enough for loss/avg
    $isWin = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
    $isV6 = ns_agent_is_ipv6($ip);
    $wall = max(10, min(45, $timeout));

    $argsList = [];
    if ($isWin) {
        $argsList[] = $isV6
            ? ['mtr', '-6', '-n', '-r', '-c', (string) $cycles, '-m', (string) $maxHops, '--timeout', '1', $ip]
            : ['mtr', '-n', '-r', '-c', (string) $cycles, '-m', (string) $maxHops, '--timeout', '1', $ip];
    } else {
        if ($isV6) {
            $argsList[] = ['mtr', '-6', '-n', '-r', '-c', (string) $cycles, '-m', (string) $maxHops, '--timeout', '1', $ip];
            $argsList[] = ['mtr', '-6', '-n', '--report', '--report-cycles', (string) $cycles, '--max-ttl', (string) $maxHops, $ip];
        } else {
            $argsList[] = ['mtr', '-n', '-r', '-c', (string) $cycles, '-m', (string) $maxHops, '--timeout', '1', $ip];
            $argsList[] = ['mtr', '-n', '--report', '--report-cycles', (string) $cycles, '--max-ttl', (string) $maxHops, $ip];
        }
    }

    foreach ($argsList as $args) {
        $out = ns_agent_proc($args, $wall);
        $text = $out['stdout'] . "\n" . $out['stderr'];
        if (preg_match('/not found|No such file|not recognized/i', $text) && trim($out['stdout']) === '') {
            continue;
        }
        $hops = ns_agent_parse_mtr($text);
        if ($hops) {
            return [
                'target' => $ip,
                'method' => 'mtr',
                'cycles' => $cycles,
                'hops' => $hops,
            ];
        }
    }

    // Fast fallback: one-shot traceroute so the check still returns a useful path
    $trace = ns_agent_traceroute($target, $timeout);
    if (!empty($trace['hops'])) {
        $trace['method'] = 'traceroute';
        $trace['note'] = 'mtr not available on this node; traceroute used';
        $trace['cycles'] = 1;
        return $trace;
    }

    return [
        'error' => 'mtr unavailable on this node (install mtr)',
        'target' => $ip,
        'method' => 'mtr',
    ];
}

/**
 * @return list<array<string, mixed>>
 */
function ns_agent_parse_traceroute(string $text): array
{
    $hops = [];
    foreach (preg_split('/\r\n|\n|\r/', $text) as $line) {
        $line = trim($line);
        if ($line === '' || !preg_match('/^(\d+)\s+/', $line, $hm)) {
            continue;
        }
        $hop = (int) $hm[1];
        $rest = preg_replace('/^\d+\s+/', '', $line) ?? '';
        if (preg_match('/^[\s\*]+$/', $rest)) {
            $hops[] = ['hop' => $hop, 'host' => '*', 'loss' => 100.0, 'sent' => 1, 'last' => null, 'avg' => null];
            continue;
        }

        $host = '*';
        $rtts = [];
        if (preg_match('/^((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f:]+)\s+(.+)$/i', $rest, $m)) {
            $host = $m[1];
            if (preg_match_all('/([\d.]+)\s*ms/i', $m[2], $rm)) {
                foreach ($rm[1] as $v) {
                    $rtts[] = round((float) $v, 3);
                }
            }
        } elseif (preg_match('/^([^\s]+)\s+\(((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f:]+)\)\s+(.+)$/i', $rest, $m)) {
            $host = $m[2];
            if (preg_match_all('/([\d.]+)\s*ms/i', $m[3], $rm)) {
                foreach ($rm[1] as $v) {
                    $rtts[] = round((float) $v, 3);
                }
            }
        } elseif (str_contains($rest, '*') && !preg_match('/\d+\.\d+\s*ms/i', $rest)) {
            $hops[] = ['hop' => $hop, 'host' => '*', 'loss' => 100.0, 'sent' => 1, 'last' => null, 'avg' => null];
            continue;
        } else {
            continue;
        }

        $sent = max(1, count($rtts) ?: 1);
        $loss = $rtts ? 0.0 : 100.0;
        $avg = $rtts ? round(array_sum($rtts) / count($rtts), 3) : null;
        $hops[] = [
            'hop' => $hop,
            'host' => $host,
            'loss' => $loss,
            'sent' => $sent,
            'last' => $rtts ? $rtts[count($rtts) - 1] : null,
            'avg' => $avg,
            'best' => $rtts ? min($rtts) : null,
            'worst' => $rtts ? max($rtts) : null,
        ];
    }
    return ns_agent_unique_hops($hops);
}

/**
 * @return list<array<string, mixed>>
 */
function ns_agent_parse_tracert(string $text): array
{
    $hops = [];
    foreach (preg_split('/\r\n|\n|\r/', $text) as $line) {
        $line = trim($line);
        if (!preg_match('/^\s*(\d+)\s+/', $line, $hm)) {
            continue;
        }
        $hop = (int) $hm[1];
        if (preg_match('/Request timed out/i', $line) || preg_match('/^\s*\d+(\s+\*)+\s*$/', $line)) {
            $hops[] = ['hop' => $hop, 'host' => '*', 'loss' => 100.0, 'sent' => 3, 'last' => null, 'avg' => null];
            continue;
        }
        $rtts = [];
        if (preg_match_all('/<\s*1\s*ms|([\d.]+)\s*ms/i', $line, $rm)) {
            foreach ($rm[0] as $i => $full) {
                if (stripos($full, '<') !== false) {
                    $rtts[] = 0.5;
                } elseif (isset($rm[1][$i]) && $rm[1][$i] !== '') {
                    $rtts[] = round((float) $rm[1][$i], 3);
                }
            }
        }
        $host = '*';
        if (preg_match('/((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f:]{2,})\s*$/i', $line, $im)) {
            $host = $im[1];
        }
        if ($host === '*' && !$rtts) {
            continue;
        }
        $avg = $rtts ? round(array_sum($rtts) / count($rtts), 3) : null;
        $hops[] = [
            'hop' => $hop,
            'host' => $host,
            'loss' => $rtts ? 0.0 : 100.0,
            'sent' => max(1, count($rtts)),
            'last' => $rtts ? $rtts[count($rtts) - 1] : null,
            'avg' => $avg,
            'best' => $rtts ? min($rtts) : null,
            'worst' => $rtts ? max($rtts) : null,
        ];
    }
    return ns_agent_unique_hops($hops);
}

/**
 * @return list<array<string, mixed>>
 */
function ns_agent_parse_tracepath(string $text): array
{
    $hops = [];
    foreach (preg_split('/\r\n|\n|\r/', $text) as $line) {
        $line = trim($line);
        // " 1:  10.0.0.1  0.4ms" or " 1:  no reply"
        if (!preg_match('/^(\d+):\s+(.+)$/', $line, $m)) {
            continue;
        }
        $hop = (int) $m[1];
        $rest = trim($m[2]);
        if (stripos($rest, 'no reply') !== false) {
            $hops[] = ['hop' => $hop, 'host' => '*', 'loss' => 100.0, 'sent' => 1, 'last' => null, 'avg' => null];
            continue;
        }
        $host = '*';
        $rtt = null;
        if (preg_match('/^((?:\d{1,3}\.){3}\d{1,3}|[0-9a-f:]+)/i', $rest, $hm)) {
            $host = $hm[1];
        }
        if (preg_match('/([\d.]+)\s*ms/i', $rest, $rm)) {
            $rtt = round((float) $rm[1], 3);
        }
        $hops[] = [
            'hop' => $hop,
            'host' => $host,
            'loss' => $rtt === null ? 100.0 : 0.0,
            'sent' => 1,
            'last' => $rtt,
            'avg' => $rtt,
            'best' => $rtt,
            'worst' => $rtt,
        ];
    }
    return ns_agent_unique_hops($hops);
}

/**
 * @return list<array<string, mixed>>
 */
function ns_agent_parse_mtr(string $text): array
{
    $hops = [];
    foreach (preg_split('/\r\n|\n|\r/', $text) as $line) {
        $line = rtrim($line);
        // "  1.|-- 192.168.1.1         0.0%     3    1.2   1.3   1.1   1.5   0.2"
        if (!preg_match('/^\s*(\d+)\.\|--\s+(\S+)\s+([\d.]+)%\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s+([\d.]+))?/', $line, $m)) {
            // Some builds: "1. AS???" style without |--
            if (!preg_match('/^\s*(\d+)\.\s+(\S+)\s+([\d.]+)%\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/', $line, $m)) {
                continue;
            }
        }
        $host = $m[2];
        if ($host === '???' || str_starts_with($host, '?')) {
            $host = '*';
        }
        $hops[] = [
            'hop' => (int) $m[1],
            'host' => $host,
            'loss' => round((float) $m[3], 1),
            'sent' => (int) $m[4],
            'last' => round((float) $m[5], 3),
            'avg' => round((float) $m[6], 3),
            'best' => round((float) $m[7], 3),
            'worst' => round((float) $m[8], 3),
            'stdev' => isset($m[9]) && $m[9] !== '' ? round((float) $m[9], 3) : null,
        ];
    }
    return ns_agent_unique_hops($hops);
}

/**
 * @param list<array<string, mixed>> $hops
 * @return list<array<string, mixed>>
 */
function ns_agent_unique_hops(array $hops): array
{
    $by = [];
    foreach ($hops as $h) {
        $n = (int) ($h['hop'] ?? 0);
        if ($n < 1) {
            continue;
        }
        $by[$n] = $h;
    }
    ksort($by, SORT_NUMERIC);
    return array_values($by);
}

function ns_agent_proc(array $args, int $timeout): array
{
    $descriptors = [
        0 => ['pipe', 'r'],
        1 => ['pipe', 'w'],
        2 => ['pipe', 'w'],
    ];
    $proc = @proc_open($args, $descriptors, $pipes, null, null, ['bypass_shell' => true]);
    if (!is_resource($proc)) {
        return ['ok' => false, 'stdout' => '', 'stderr' => 'proc_open failed'];
    }
    fclose($pipes[0]);
    stream_set_blocking($pipes[1], false);
    stream_set_blocking($pipes[2], false);
    $stdout = '';
    $stderr = '';
    $start = time();
    do {
        $stdout .= stream_get_contents($pipes[1]) ?: '';
        $stderr .= stream_get_contents($pipes[2]) ?: '';
        $status = proc_get_status($proc);
        if (!$status['running']) {
            break;
        }
        usleep(50000);
    } while ((time() - $start) < $timeout);

    $status = proc_get_status($proc);
    if ($status['running']) {
        proc_terminate($proc);
    }
    $stdout .= stream_get_contents($pipes[1]) ?: '';
    $stderr .= stream_get_contents($pipes[2]) ?: '';
    fclose($pipes[1]);
    fclose($pipes[2]);
    proc_close($proc);
    return ['ok' => true, 'stdout' => $stdout, 'stderr' => $stderr];
}

function ns_agent_callback(string $url, string $requestId, string $nodeId, $result): void
{
    $exp = time() + 60;
    $nonce = bin2hex(random_bytes(12));
    $resultHash = hash('sha256', json_encode($result, JSON_UNESCAPED_SLASHES));
    $signFields = [
        'request_id' => $requestId,
        'node_id' => $nodeId,
        'exp' => $exp,
        'nonce' => $nonce,
        'result' => $resultHash,
    ];
    $payload = [
        'request_id' => $requestId,
        'node_id' => $nodeId,
        'exp' => $exp,
        'nonce' => $nonce,
        'result' => $result,
        'sig' => hash_hmac('sha256', ns_agent_hmac_canonical($signFields), HUB_HMAC_SECRET),
    ];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 8,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . NODE_TOKEN,
        ],
        CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_SLASHES),
        CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
    ]);
    curl_exec($ch);
    curl_close($ch);
}
