#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
 * Pulsar reference agent (CLI). One file, no dependencies beyond php-curl.
 *
 * Pair:  php pulsar-agent.php pair <CODE> [--api=https://astrina.io] [--label=my-pc]
 * Run:   php pulsar-agent.php run [--once] [--config=~/.pulsar-agent.json]
 *
 * The agent only ever executes tasks handed to it by the Pulsar dispatcher:
 * public http(s) GET/HEAD probes and page-timing measurements. It never follows
 * server instructions to non-http schemes or private address space (double-checked
 * client-side below), never sends cookies, and identifies itself with its own UA.
 */

const AGENT_VER = '0.12.1';  // 0.12.1 — mTLS работает из коробки: socat в бандле держателя (block_socat_bin, self-contained)
const AGENT_UA  = 'PulsarAgent/0.1 (+https://astrina.io/pulsar)';

function cfg_path(): string {
    foreach ($GLOBALS['argv'] as $a) if (str_starts_with($a, '--config=')) return substr($a, 9);
    return ($_SERVER['HOME'] ?? '.') . '/.pulsar-agent.json';
}
function cfg_load(): array {
    $p = cfg_path();
    return is_file($p) ? (json_decode((string)file_get_contents($p), true) ?: []) : [];
}
function cfg_save(array $c): void {
    file_put_contents(cfg_path(), json_encode($c, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
    @chmod(cfg_path(), 0600);
}
/** F19 Offline result queue. A node often loses connectivity for a few minutes (laptop lid, phone
 *  handover, flaky VPS). Results computed in that window used to be dropped on the floor: the work
 *  was done, the task then expired unpaid, and the device looked unreliable through no fault of its
 *  own. Persist unsent results and push them on the next cycle instead. Bounded so a long outage
 *  cannot grow a file without limit, and stale entries are discarded because the server will have
 *  expired those assignments anyway. */
function queue_path(array $c): string { return cfg_path() . '.queue'; }
function queue_load(array $c): array {
    $j = @json_decode((string)@file_get_contents(queue_path($c)), true);
    if (!is_array($j)) return [];
    $cut = time() - 900;                       // assignments die server-side ~10 min; 15 is generous
    return array_values(array_filter($j, fn($e) => (int)($e['at'] ?? 0) > $cut));
}
function queue_add(array $c, array $results): void {
    $q = queue_load($c);
    foreach ($results as $r) $q[] = ['at' => time(), 'r' => $r];
    if (count($q) > 200) $q = array_slice($q, -200);
    @file_put_contents(queue_path($c), json_encode($q, JSON_UNESCAPED_SLASHES), LOCK_EX);
}
function queue_clear(array $c): void { @unlink(queue_path($c)); }

function say(string $m): void { fwrite(STDERR, '[' . date('H:i:s') . "] $m\n"); }

/** SECURITY (session audit): abort a curl fetch once it exceeds $maxBytes so a hostile target cannot
 *  exhaust the device's memory with a multi-GB response. On abort curl_exec() returns false, which the
 *  caller already treats as a fetch error, so normal-sized content behaves exactly as before. */
function agent_cap_curl($ch, int $maxBytes = 67108864): void {   // 64 MB: caps GB-scale abuse, never truncates legit content
    @curl_setopt($ch, CURLOPT_NOPROGRESS, false);
    @curl_setopt($ch, CURLOPT_XFERINFOFUNCTION, function ($c, $dltotal, $dlnow) use ($maxBytes) {
        return ($dlnow > $maxBytes || $dltotal > $maxBytes) ? 1 : 0;
    });
}
/** audit: capped fetch that REFUSES the result when the peer we actually connected to is
 *  private/reserved — defeats DNS-rebind (validated host re-resolves at connect) and
 *  redirect-to-internal SSRF uniformly, without changing any per-handler logic. Returns false
 *  exactly like curl_exec() on failure/refusal, a path every caller already handles. */
function agent_exec($ch, int $maxBytes = 67108864) {
    agent_cap_curl($ch, $maxBytes);
    $r = curl_exec($ch);
    if ($r === false) return false;
    // Through a proxy (all_proxy/http(s)_proxy — as the srv2 node uses), CURLINFO_PRIMARY_IP is the
    // PROXY, not the target: the peer check is meaningless AND would wrongly refuse a private-addressed
    // proxy relay. Proxied nodes rely on target_ok() (destination validated pre-fetch) + the proxy's
    // own egress policy. Direct nodes (phones, unproxied CLI) still get the rebind/redirect guard.
    static $proxied = null;
    if ($proxied === null)
        $proxied = (getenv('all_proxy') || getenv('ALL_PROXY') || getenv('https_proxy')
                    || getenv('HTTPS_PROXY') || getenv('http_proxy') || getenv('HTTP_PROXY')) ? true : false;
    if (!$proxied) {
        $peer = (string)curl_getinfo($ch, CURLINFO_PRIMARY_IP);
        if ($peer !== '' && !filter_var($peer, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return false;
    }
    return $r;
}

/** #182/#189 Best-effort local health snapshot: 1-min load, used-memory %, uptime, and the agent's
 *  OWN measured egress for the UTC day (the "what did the agent actually consume" figure). Every
 *  field is guarded for non-Linux / restricted hosts (returns 0 for anything it cannot read); no
 *  secrets, no PII. */
function agent_health(string $budgetFile): array {
    $load1 = 0.0;
    if (function_exists('sys_getloadavg')) { $la = @sys_getloadavg(); if (is_array($la)) $load1 = (float)($la[0] ?? 0); }
    $memPct = 0;
    if (@is_readable('/proc/meminfo')) {
        $mi = (string)@file_get_contents('/proc/meminfo');
        if (preg_match('/MemTotal:\s+(\d+)/', $mi, $t) && preg_match('/MemAvailable:\s+(\d+)/', $mi, $av) && (int)$t[1] > 0)
            $memPct = (int)round(100 * (1 - (int)$av[1] / (int)$t[1]));
    }
    $uptime = 0;
    if (@is_readable('/proc/uptime')) { $uptime = (int)(float)strtok((string)@file_get_contents('/proc/uptime'), ' '); }
    $bytesToday = 0;
    $ub = @json_decode((string)@file_get_contents($budgetFile), true) ?: [];
    if (($ub['day'] ?? '') === gmdate('Y-m-d')) $bytesToday = (int)($ub['bytes'] ?? 0);
    return ['load1' => round($load1, 2), 'mem_pct' => $memPct, 'uptime_s' => $uptime, 'bytes_today' => $bytesToday];
}

/** #187 Capability profiles. 'lite' = liveness only (cheapest), 'full' = all monitoring/probing
 *  checks (default), 'ai' = full plus the on-device inference kinds. Unknown name falls back to full. */
/** Free RAM in whole GB, MEASURED — never assumed. Returns 0 when it cannot be established, and
 *  0 means "do not claim AI capacity", because the shared-model counter must reflect real hardware
 *  offered, not optimism. */
function agent_ram_gb(): int {
    // Linux: MemAvailable is the honest number (free + reclaimable), not MemFree.
    if (is_readable('/proc/meminfo')) {
        $m = (string)@file_get_contents('/proc/meminfo');
        if (preg_match('/MemAvailable:\s+(\d+) kB/', $m, $x)) return (int)floor(((int)$x[1]) / 1048576);
        if (preg_match('/MemTotal:\s+(\d+) kB/', $m, $x))     return (int)floor(((int)$x[1]) / 1048576);
    }
    if (PHP_OS_FAMILY === 'Darwin' && function_exists('shell_exec')) {
        $b = (int)trim((string)@shell_exec('sysctl -n hw.memsize 2>/dev/null'));
        if ($b > 0) return (int)floor($b / 1073741824);
    }
    if (PHP_OS_FAMILY === 'Windows' && function_exists('shell_exec')) {
        $o = (string)@shell_exec('wmic ComputerSystem get TotalPhysicalMemory 2>NUL');
        if (preg_match('/(\d{9,})/', $o, $x)) return (int)floor(((float)$x[1]) / 1073741824);
    }
    return 0;
}

/** F106 What kind of arithmetic this machine has, MEASURED by looking for the runtime, never
 *  guessed from the OS. Returns '' when nothing can be established — the planner treats a missing
 *  field as "unknown", which is different from "none", and unknown simply does not get the work. */
function agent_accel(): string {
    if (PHP_OS_FAMILY === 'Darwin') {
        // Apple silicon runs Metal; an Intel Mac does not, and the difference is visible here.
        if (function_exists('shell_exec')) {
            $b = strtolower((string)@shell_exec('sysctl -n machdep.cpu.brand_string 2>/dev/null'));
            if (strpos($b, 'apple') !== false) return 'metal';
        }
        return 'cpu';
    }
    if (PHP_OS_FAMILY === 'Linux') {
        if (@is_dir('/proc/driver/nvidia') || @is_readable('/dev/nvidiactl')) return 'cuda';
        foreach (glob('/sys/class/kfd*') ?: [] as $ignored) return 'rocm';        // AMD compute
        if (@is_readable('/dev/kfd')) return 'rocm';
    }
    return 'cpu';
}

/** F106 GPU memory in whole GB, or 0 when there is no GPU or it cannot be read without extra tools. */
function agent_vram_gb(): int {
    if (PHP_OS_FAMILY === 'Linux' && function_exists('shell_exec')) {
        $o = (string)@shell_exec('nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null');
        $mb = (int)trim(strtok($o, "\n") ?: '0');
        if ($mb > 0) return (int)floor($mb / 1024);
    }
    return 0;
}

function profile_caps(string $profile): array {
    $lite = ['uptime' => 1, 'perf' => 1];
    $full = $lite + ['ssl' => 1, 'content' => 1, 'keyword' => 1, 'dns' => 1, 'port' => 1, 'api' => 1,
        'serp' => 1, 'compress' => 1, 'ipv6' => 1, 'mixed' => 1, 'redirect' => 1, 'smtp' => 1, 'robots' => 1,
        'captcha' => 1, 'integrity' => 1, 'cdn' => 1, 'geoblock' => 1, 'journey' => 1, 'dnssec' => 1,
        'apprank' => 1, 'trace' => 1, 'linkaudit' => 1, 'rss' => 1, 'meta' => 1, 'email' => 1,
        'favicon' => 1, 'blacklist' => 1, 'price' => 1, 'spelling' => 1, 'sitemap' => 1, 'unshorten' => 1, 'cloaking' => 1, 'aicontent' => 1, 'portscan' => 1,
        'redirloc' => 1];
    $caps = match ($profile) {
        'lite' => $lite,
        // NOTE: 'neural' is deliberately NOT declared here. This agent implements infer.embed
        // (SimHash over normalised text) and has no neural-embedding runtime — that lives in the
        // browser extension (transformers.js/WebGPU). Advertising it would earn this node
        // infer.neural tasks it cannot perform, and it would lose trust for failing work it was
        // never able to do.
        'ai'   => $full + ['infer' => 1],
        default => $full + ['infer' => 1],   // 'full' keeps the historical default (incl. infer)
    };
    // Declare AI CAPACITY, not just the ability to run an inference task. The network's
    // "own model" counter reads caps.ai + caps.ram_gb; without these it can only ever read 0,
    // however capable the fleet actually is — which is exactly what was happening. Reported only
    // for profiles that already opted into inference, and only when the memory is really there.
    // Идея №5: `videopack` объявляется, только если машина ДОКАЗАЛА кодировщик делом
    // (videopack_ready). Профиль lite остаётся минимальным намеренно: там это лишний трафик.
    if ($profile !== 'lite' && videopack_ready()) $caps['videopack'] = 1;
    $ram = agent_ram_gb();
    if ($ram > 0) $caps['ram_gb'] = $ram;
    if (!empty($caps['infer']) && $ram >= 8) $caps['ai'] = 1;
    // F106 What a pipeline planner needs beyond "ai: 1": how much of a model this machine can hold,
    // what runs the arithmetic, and how many blocks it is willing to keep resident. Only reported
    // once the node has actually claimed AI capacity — a lite node advertising GPU details would
    // just be noise in the registry.
    if (!empty($caps['ai'])) {
        $acc = agent_accel();
        if ($acc !== '') $caps['accel'] = $acc;
        $v = agent_vram_gb();
        if ($v > 0) $caps['vram_gb'] = $v;
        // One resident block per 8 GB offered, never more than four: the rest of the machine has
        // to keep working. cfg model_slots overrides it for an operator who knows better.
        $slots = (int)(cfg_load()['model_slots'] ?? 0);
        if ($slots <= 0) $slots = max(1, min(4, (int)floor($ram / 8)));
        $caps['model_slots'] = $slots;
        // S1: claim generation capability ONLY when a local model runtime actually answers.
        // Declaring 'aigen' without Ollama would earn infer.generate work the device cannot run
        // and lose it trust — the same rule that keeps 'neural' undeclared on this CLI agent.
        if (ollama_available()) $caps['aigen'] = 1;
        // S2 block holder: advertise an rpc endpoint only when a block runtime exists AND the
        // operator enabled serving AND a reachable endpoint is configured — an unreachable one
        // would waste a chain slot on a holder that never answers.
        $be = block_runtime_endpoint();
        if ($be !== '') { $caps['rpc_endpoint'] = $be; $caps['aiblock'] = 1; }
    }
    return $caps;
}

/** #180 Work-hours window. cfg active_hours = "8-22" (local clock) rests the node outside the
 *  window; "22-6" is a valid overnight span. Empty/absent = always on. Returns true when allowed. */
function within_hours($c): bool {
    /* F11 The server may carry a window the owner set from their phone or dashboard. It WINS over
     * the local config: the owner set it last, from the surface that shows it back to them, and a
     * stale line in a config file on a machine they may not even have access to should not quietly
     * override what the account says. Empty from the server means "no server-side window", which
     * falls through to the local setting rather than erasing it. */
    $srv = trim((string)($GLOBALS['__pulsar_srv_hours'] ?? ''));
    $w = $srv !== '' ? $srv : trim((string)($c['active_hours'] ?? ''));
    if ($w === '' || !preg_match('/^(\d{1,2})\s*-\s*(\d{1,2})$/', $w, $m)) return true;
    $from = (int)$m[1] % 24; $to = (int)$m[2] % 24; $h = (int)date('G');
    /* "0-24" is how a person writes "all day", and it used to mean the exact opposite: 24 % 24 = 0,
     * so the window became 0..0 — a span of zero hours, and the node rested forever while its owner
     * believed it was working around the clock. Any window whose ends coincide is read as "always",
     * which is the only reading that is ever useful. */
    if ($from === $to) return true;
    return $from < $to ? ($h >= $from && $h < $to) : ($h >= $from || $h < $to);
}

/** #188 Local result cache. A check just computed for the same (kind,target) is reused for a short
 *  TTL, so the same deterministic probe isn't refetched when the dispatcher hands it out again in
 *  quick succession — saves the device's network/CPU. Only successful results are cached; the TTL
 *  is short enough that a real change is never masked, and quorum/canary still validate every submit. */
function task_ckey(array $t): string {
    $p = is_array($t['payload'] ?? null) ? $t['payload'] : [];
    ksort($p);
    return hash('sha256', (string)($t['kind'] ?? '') . '|' . json_encode($p, JSON_UNESCAPED_SLASHES));
}
function task_cache_get(string $file, array $t, int $ttl = 90): ?array {
    $m = @json_decode((string)@file_get_contents($file), true) ?: [];
    $e = $m[task_ckey($t)] ?? null;
    return (is_array($e) && (time() - (int)($e['at'] ?? 0)) < $ttl && is_array($e['res'] ?? null)) ? $e['res'] : null;
}
function task_cache_put(string $file, array $t, array $res): void {
    if (empty($res['ok'])) return;
    $m = @json_decode((string)@file_get_contents($file), true) ?: [];
    $now = time();
    foreach ($m as $k => $e) if (($now - (int)($e['at'] ?? 0)) > 300) unset($m[$k]);   // prune
    $m[task_ckey($t)] = ['at' => $now, 'res' => $res];
    if (count($m) > 200) $m = array_slice($m, -200, null, true);
    @file_put_contents($file, json_encode($m));
    @chmod($file, 0600);
}

/** #246 robots.txt enforcement. Returns true iff PulsarAgent may fetch $url per the origin's
 *  robots.txt (cached per-origin for this run). Fail-OPEN: absent/unreachable/non-200 = allowed.
 *  UA-specific group wins over '*'; longest matching path pattern wins, ties prefer Allow. Only the
 *  page-CONTENT kinds consult this, and only when the task carries respect_robots — owner monitoring
 *  of one's OWN site passes no flag and is never blocked. */
function robots_allows(string $url): bool {
    $u = parse_url($url);
    if (!isset($u['scheme'], $u['host'])) return true;
    $origin = $u['scheme'] . '://' . $u['host'] . (isset($u['port']) ? ':' . (int)$u['port'] : '');
    $path = ($u['path'] ?? '/') ?: '/';
    if (isset($u['query'])) $path .= '?' . $u['query'];
    static $cache = [];
    if (!array_key_exists($origin, $cache)) {
        $ch = curl_init($origin . '/robots.txt');
        curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8,
            CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 2, CURLOPT_USERAGENT => AGENT_UA]);
        $body = agent_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
        $cache[$origin] = ($body !== false && $code === 200 && strlen((string)$body) < 512000) ? (string)$body : '';
    }
    if ($cache[$origin] === '') return true;

    $ua = 'pulsaragent';
    $groups = ['ua' => [], 'star' => []];
    $applies = [];
    foreach (preg_split('/\r?\n/', $cache[$origin]) as $ln) {
        $ln = preg_replace('/#.*$/', '', $ln);
        if (!preg_match('/^\s*([A-Za-z-]+)\s*:\s*(.*?)\s*$/', $ln, $m)) {
            if (trim($ln) === '') $applies = [];
            continue;
        }
        $field = strtolower($m[1]); $val = $m[2];
        if ($field === 'user-agent') {
            $t = strtolower(trim($val));
            $applies[] = ($t === '*') ? 'star' : ((str_contains($ua, $t) || str_contains($t, $ua)) ? 'ua' : 'skip');
        } elseif ($field === 'allow' || $field === 'disallow') {
            foreach ($applies as $bucket) if ($bucket === 'ua' || $bucket === 'star') $groups[$bucket][] = [$field, $val];
        }
    }
    $rules = $groups['ua'] ?: $groups['star'];
    if (!$rules) return true;

    $decision = true; $bestLen = -1;
    foreach ($rules as [$type, $pat]) {
        if ($type === 'disallow' && $pat === '') continue;
        if (robots_path_match($pat, $path)) {
            $len = strlen($pat);
            if ($len > $bestLen || ($len === $bestLen && $type === 'allow')) { $bestLen = $len; $decision = ($type === 'allow'); }
        }
    }
    return $decision;
}

/** Match a robots path pattern (with * wildcard and $ end-anchor) against a request path. */
function robots_path_match(string $pat, string $path): bool {
    if ($pat === '') return true;
    $anchor = str_ends_with($pat, '$');
    if ($anchor) $pat = substr($pat, 0, -1);
    $re = '';
    foreach (str_split($pat) as $ch) $re .= $ch === '*' ? '.*' : preg_quote($ch, '~');
    return (bool)preg_match('~^' . $re . ($anchor ? '$' : '') . '~', $path);
}

/** #190 Plugin architecture. A third party can add a check kind WITHOUT touching the core agent:
 *  drop a PHP file in <config-dir>/plugins/ defining pulsar_plugin_<name>(array $payload): array.
 *  The dispatcher then assigns 'plugin.<name>' tasks and this agent runs them. Loaded once; only
 *  regular *.php files in that single directory are included (no recursion, no URLs). Returns the
 *  list of plugin names that are now callable. */
function load_plugins(): array {
    static $loaded = null;
    if ($loaded !== null) return $loaded;
    $loaded = [];
    $dir = dirname(cfg_path()) . '/plugins';
    if (is_dir($dir)) {
        foreach (glob($dir . '/*.php') ?: [] as $f)
            if (is_file($f)) { try { require_once $f; } catch (\Throwable $e) { say('plugin load failed: ' . basename($f)); } }
        foreach (get_defined_functions()['user'] as $fn)
            if (str_starts_with($fn, 'pulsar_plugin_')) $loaded[] = substr($fn, 14);
    }
    return $loaded;
}

/** Authenticated call to the Pulsar API (bearer device secret over TLS). */
/**
 * F57 Prove a freshly minted key before registering it.
 *
 * The server verifies our signature over a sample IT chooses. If our canonical JSON differs from
 * theirs by so much as a character the key is refused, and we keep working unsigned exactly as
 * this agent did before signing existed. Registering on our own word would do the opposite of
 * what signing is for: every later result rejected, earnings stopping without a word.
 */
function agent_register_key(array &$c): void {
    if (!function_exists('sodium_crypto_sign_keypair')) return;
    $probe = api($c, 'key-probe', []);                    // пустое тело = «дай образец»
    $msg = (string)($probe['message'] ?? '');
    if ($msg === '') return;                              // старый сервер без рукопожатия

    $kp = sodium_crypto_sign_keypair();
    $sk = bin2hex(sodium_crypto_sign_secretkey($kp));
    $pk = bin2hex(sodium_crypto_sign_publickey($kp));
    $r  = api($c, 'key-probe', ['pubkey' => $pk,
                                'sig' => bin2hex(sodium_crypto_sign_detached($msg, hex2bin($sk)))]);
    // Переключаться можно только на ПРИНЯТЫЙ ключ. ok:true при adopted:false означает «у меня
    // уже есть другой ключ» — сохранить свой означало бы подписывать тем, чего сервер не знает,
    // и остаться без заработка молча.
    if (empty($r['ok']) || empty($r['adopted'])) { say('signing key refused by the server — continuing unsigned'); return; }

    $c['sign_sk'] = $sk; $c['sign_pk'] = $pk;
    cfg_save($c);
    say('signing key registered');
}

function api(array $c, string $ep, array $body): array {
    $raw = (string)json_encode($body, JSON_UNESCAPED_SLASHES);
    $ch  = curl_init(rtrim($c['api'], '/') . '/api/v1/node/' . $ep);
    $hdr = ['Content-Type: application/json'];
    if (!empty($c['secret'])) $hdr[] = 'X-Node-Key: ' . $c['secret'];
    curl_setopt_array($ch, [
        CURLOPT_POST => true, CURLOPT_POSTFIELDS => $raw, CURLOPT_HTTPHEADER => $hdr,
        CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_USERAGENT => AGENT_UA,
    ]);
    $out = agent_exec($ch);
    if ($out === false) { say('api ' . $ep . ' failed: ' . curl_error($ch)); curl_close($ch); return ['ok' => false]; }
    curl_close($ch);
    $j = json_decode((string)$out, true);
    return is_array($j) ? $j : ['ok' => false, 'raw' => substr((string)$out, 0, 200)];
}

/** Client-side safety: refuse non-http and private/reserved targets even if asked. */
function target_ok(string $url): bool {
    $p = parse_url($url);
    if (!$p || !in_array($p['scheme'] ?? '', ['http', 'https'], true) || empty($p['host'])) return false;
    // #101 personal exclude list: hosts the contributor never wants probed from their device.
    // Set in the config file as {"exclude": ["example.com", "internal.local"]} — a suffix match.
    $host = strtolower((string)$p['host']);
    foreach ((array)($GLOBALS['__pulsar_exclude'] ?? []) as $ex) {
        $ex = strtolower(trim((string)$ex));
        if ($ex !== '' && ($host === $ex || str_ends_with($host, '.' . $ex))) return false;
    }
    $ips = @gethostbynamel($p['host']) ?: [];
    if (!$ips && filter_var($p['host'], FILTER_VALIDATE_IP)) $ips = [$p['host']];
    // audit: also resolve+validate AAAA — gethostbynamel is IPv4-only, so a host with a public A
    // and a private AAAA (::1 / fc00::) would otherwise pass and curl could connect over IPv6.
    if (!filter_var($p['host'], FILTER_VALIDATE_IP))
        foreach (@dns_get_record($p['host'], DNS_AAAA) ?: [] as $rec)
            if (!empty($rec['ipv6'])) $ips[] = $rec['ipv6'];
    foreach ($ips as $ip)
        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return false;
    return (bool)$ips;
}

/** uptime.probe / perf.measure — one HTTP fetch with timings. */
function do_probe(array $payload, bool $body): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_NOBODY => !$body && strtoupper((string)($payload['method'] ?? 'GET')) === 'HEAD',
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA,
        CURLOPT_COOKIEFILE => '', CURLOPT_CERTINFO => true,
    ]);
    $out = agent_exec($ch);
    $i   = curl_getinfo($ch);
    $tlsDays = null;
    if (!empty($i['certinfo'][0]['Expire date'])) {
        $exp = strtotime($i['certinfo'][0]['Expire date']);
        if ($exp) $tlsDays = (int)floor(($exp - time()) / 86400);
    }
    $err = $out === false ? curl_error($ch) : null;
    curl_close($ch);
    return [
        'ok'       => $err === null,
        'code'     => (int)($i['http_code'] ?? 0),
        'ttfb_ms'  => (int)round(($i['starttransfer_time'] ?? 0) * 1000),
        'total_ms' => (int)round(($i['total_time'] ?? 0) * 1000),
        'bytes'    => (int)($i['size_download'] ?? 0),
        'tls_days' => $tlsDays,
        'err'      => $err ? substr($err, 0, 120) : null,
    ];
}

/** Shared text normalization for content.watch — MUST match the extension byte-for-byte
 * so replicas hash identically. Strip scripts/styles/tags, collapse whitespace, trim. */
function normalize_text(string $html): string {
    $s = preg_replace('#<(script|style|noscript)\b[^>]*>.*?</\1>#is', ' ', $html) ?? $html;
    $s = preg_replace('#<!--.*?-->#s', ' ', $s) ?? $s;
    $s = preg_replace('#<[^>]+>#', ' ', $s) ?? $s;
    $s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $s = preg_replace('/\s+/u', ' ', $s) ?? $s;
    return trim($s);
}

/** ssl.audit — TLS validity + presence of hardening headers. */
function do_ssl(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url) || !str_starts_with(strtolower($url), 'https://'))
        return ['ok' => false, 'err' => 'https_required'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_NOBODY => true, CURLOPT_HEADER => true, CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA,
        CURLOPT_COOKIEFILE => '', CURLOPT_CERTINFO => true,
    ]);
    /* F27 The negotiated protocol and cipher are not in curl_getinfo(), but libcurl states them in
     * its verbose log ("SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256"). Reading them there
     * costs no extra connection and adds no new outbound path — it is the same request. */
    $vf = fopen('php://temp', 'w+');
    curl_setopt($ch, CURLOPT_VERBOSE, true);
    curl_setopt($ch, CURLOPT_STDERR, $vf);
    $out = agent_exec($ch);
    $i   = curl_getinfo($ch);
    $err = $out === false ? curl_error($ch) : null;
    rewind($vf); $vlog = (string)stream_get_contents($vf); fclose($vf);
    $tlsVer = ''; $cipher = '';
    if (preg_match('~SSL connection using\s+(\S+)\s*/\s*(\S+)~i', $vlog, $vm)) {
        $tlsVer = substr(trim($vm[1]), 0, 16);
        $cipher = substr(trim($vm[2]), 0, 48);
    }
    /* Weak = what a browser now warns about or refuses: TLS below 1.2, or a cipher from the families
     * that are broken or deprecated. Anything unrecognised is NOT called weak — a scanner that cries
     * wolf on an unknown-but-fine cipher trains people to ignore it. */
    $weakProto  = ($tlsVer !== '' && preg_match('~^(SSLv2|SSLv3|TLSv1(\.[01])?)$~i', $tlsVer)) ? 1 : 0;
    $weakCipher = ($cipher !== '' && preg_match('~RC4|3DES|DES-|NULL|EXPORT|MD5|anon~i', $cipher)) ? 1 : 0;
    /* The blunt truth about measuring weak TLS from a modern client: you cannot. Point this agent at
     * a TLS-1.1 or RC4 server and OpenSSL refuses the handshake, so there is no cipher to inspect —
     * the observation is the REFUSAL, not the cipher. So the failure is classified instead, into a
     * closed vocabulary. The raw message is useless for that: it carries the local OpenSSL version
     * ("OpenSSL/3.0.20: error:0A00014D…"), which differs per device. */
    $tlsFail = '';
    if ($err !== null) {
        $e = strtolower($err);
        if (str_contains($e, 'certificate has expired') || str_contains($e, 'certificate is not yet valid')) $tlsFail = 'cert_expired';
        // "self-signed certificate IN CERTIFICATE CHAIN" is a different fault from "self-signed
        // certificate": the first is a real CA the client does not trust (or a missing intermediate),
        // the second is a certificate nobody signed. Same word, opposite fixes.
        elseif (str_contains($e, 'in certificate chain'))                                                   $tlsFail = 'cert_untrusted';
        elseif (str_contains($e, 'self-signed') || str_contains($e, 'self signed'))                         $tlsFail = 'cert_self_signed';
        elseif (str_contains($e, 'subject name') || str_contains($e, 'subject alternative'))                 $tlsFail = 'host_mismatch';
        elseif (str_contains($e, 'local issuer') || str_contains($e, 'certificate verify failed')
                || str_contains($e, 'unable to get'))                                                       $tlsFail = 'cert_untrusted';
        elseif (str_contains($e, 'handshake') || str_contains($e, 'sigalg') || str_contains($e, 'alert')
                || str_contains($e, 'protocol') || str_contains($e, 'wrong version'))                       $tlsFail = 'handshake_refused';
        elseif (str_contains($e, 'timed out') || str_contains($e, 'timeout'))                                $tlsFail = 'timeout';
        else                                                                                                $tlsFail = 'connect_failed';
    }
    $tlsDays = null;
    if (!empty($i['certinfo'][0]['Expire date'])) {
        $exp = strtotime($i['certinfo'][0]['Expire date']);
        if ($exp) $tlsDays = (int)floor(($exp - time()) / 86400);
    }
    $hdr = strtolower((string)$out);
    curl_close($ch);
    // #65 certificate chain + digits (informational — NOT in the ssl.audit ckey, which stays on
    // TLS validity + hardening headers, so a mixed old/new fleet still agrees).
    $certs = is_array($i['certinfo'] ?? null) ? $i['certinfo'] : [];
    $leaf  = $certs[0] ?? [];
    $cn = fn($dn) => preg_match('~CN\s*=\s*([^,/]+)~i', (string)$dn, $m) ? trim($m[1]) : substr((string)$dn, 0, 80);
    $issuers = [];
    foreach ($certs as $cc) if (!empty($cc['Issuer'])) $issuers[] = $cn($cc['Issuer']);
    return [
        'ok'       => $err === null,
        'code'     => (int)($i['http_code'] ?? 0),
        'tls_days' => $tlsDays,
        'hsts'     => str_contains($hdr, 'strict-transport-security:') ? 1 : 0,
        'csp'      => str_contains($hdr, 'content-security-policy:') ? 1 : 0,
        'xfo'      => str_contains($hdr, 'x-frame-options:') ? 1 : 0,
        'issuer'   => $cn($leaf['Issuer'] ?? ''),
        'subject'  => $cn($leaf['Subject'] ?? ''),
        'serial'   => substr((string)($leaf['Serial Number'] ?? ''), 0, 64),
        'sig_alg'  => substr((string)($leaf['Signature Algorithm'] ?? ''), 0, 40),
        'chain'    => count($certs),
        'chain_issuers' => array_slice(array_values(array_unique($issuers)), 0, 5),
        // F27, informational like 'chain' above — deliberately NOT in the ssl.audit consensus key.
        // A fleet mixing agent versions must still agree on the posture; older agents do not report
        // these at all, and putting them in the key would split quorum on every release.
        'tls_ver'  => $tlsVer,
        'cipher'   => $cipher,
        // null, not 0, when the handshake never completed: "we did not measure this" and "we measured
        // it and it is fine" are different answers, and a zero here would report a site with an
        // expired certificate as having a healthy cipher and a complete chain.
        'weak_tls' => $err === null ? (int)($weakProto || $weakCipher) : null,
        // Why the handshake did not happen. 'handshake_refused' on a host that is otherwise up is
        // the practical signature of a server offering only protocols modern clients have dropped —
        // which is the honest way to report the thing weak_tls above can never observe.
        'tls_fail' => $tlsFail,
        // A server that sends only its leaf certificate validates in browsers that cache the
        // intermediate and fails in clients that do not — the classic "works for me" TLS bug.
        'chain_incomplete' => $err === null ? (int)(count($certs) <= 1) : null,
        'err'      => $err ? substr($err, 0, 120) : null,
    ];
}

/** content.watch — a 16-hex fingerprint of the page's normalized text. */
function do_content(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    if (!empty($payload['respect_robots']) && !robots_allows($url)) return ['ok' => false, 'err' => 'robots_disallow'];   // #246
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '',
    ]);
    $body = agent_exec($ch);
    $i    = curl_getinfo($ch);
    $err  = $body === false ? curl_error($ch) : null;
    curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $text = normalize_text((string)$body);
    $hash = substr(hash('sha256', $text), 0, 16);
    $out  = ['ok' => true, 'code' => (int)($i['http_code'] ?? 0), 'hash' => $hash];
    // Verified web archive: attach the normalized text ONLY when it differs from what the
    // network already knows (known_hash) and is small enough — the server hash-checks it
    // and stores one deduplicated copy. No known_hash (older dispatcher) → attach too.
    if ($hash !== (string)($payload['known_hash'] ?? '') && strlen($text) <= 65536)
        $out['body'] = $text;
    return $out;
}

/** keyword.check — is a needle present (or absent) in the page's normalized text. */
function do_keyword(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    $needle = (string)($payload['needle'] ?? '');
    if (!target_ok($url) || $needle === '') return ['ok' => false, 'err' => 'bad_input'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '',
    ]);
    $body = agent_exec($ch);
    $i    = curl_getinfo($ch);
    $err  = $body === false ? curl_error($ch) : null;
    curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $hay = mb_strtolower(normalize_text((string)$body));
    $present = mb_strpos($hay, mb_strtolower($needle)) !== false;
    $want = ($payload['mode'] ?? 'present') === 'absent' ? !$present : $present;
    return ['ok' => true, 'code' => (int)($i['http_code'] ?? 0), 'found' => $want ? 1 : 0];
}

/** link.audit — is one outbound link present, dofollow, visible, on an indexable page.
 *  Returns booleans only; the server's quorum compares them byte-for-byte. */
function do_link_audit(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    $target = strtolower(ltrim((string)($payload['target'] ?? ''), '.'));
    $target = preg_replace('/^www\./', '', $target) ?? $target;
    if (!target_ok($url) || $target === '' || !preg_match('/^[a-z0-9.-]{1,253}$/', $target))
        return ['ok' => false, 'err' => 'bad_input'];
    $hdrs = [];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 20000))),
        CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '',
        CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$hdrs) {
            if (($pp = strpos($line, ':')) !== false)
                $hdrs[strtolower(trim(substr($line, 0, $pp)))] = trim(substr($line, $pp + 1));
            return strlen($line);
        },
    ]);
    $body = agent_exec($ch);
    $i    = curl_getinfo($ch);
    $err  = $body === false ? curl_error($ch) : null;
    curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $html = (string)$body;
    $code = (int)($i['http_code'] ?? 0);

    $indexable = 1;
    if (preg_match('/\b(noindex|none)\b/i', (string)($hdrs['x-robots-tag'] ?? ''))) $indexable = 0;
    if ($indexable && preg_match_all('/<meta\b[^>]*>/is', $html, $mm)) {
        foreach ($mm[0] as $tag)
            if (preg_match('/name\s*=\s*["\']?robots["\']?/i', $tag)
                && preg_match('/content\s*=\s*["\']([^"\']*)["\']/i', $tag, $c)
                && preg_match('/\b(noindex|none)\b/i', $c[1])) { $indexable = 0; break; }
    }

    $found = 0; $follow = 0; $visible = 0;
    if (preg_match_all('/<a\b([^>]*)>(.*?)<\/a>/is', $html, $links, PREG_SET_ORDER)) {
        foreach ($links as $lk) {
            if (!preg_match('/href\s*=\s*(["\']?)([^"\'\s>]+)\1/i', $lk[1], $h)) continue;
            $host = strtolower((string)(parse_url(trim($h[2]), PHP_URL_HOST) ?: ''));
            $host = preg_replace('/^www\./', '', $host) ?? $host;
            if ($host === '' || ($host !== $target && !str_ends_with($host, '.' . $target))) continue;
            $found = 1;
            $rel = '';
            if (preg_match('/\brel\s*=\s*(["\']?)([^"\'>]*)\1/i', $lk[1], $r)) $rel = strtolower($r[2]);
            $f = preg_match('/\b(nofollow|sponsored|ugc)\b/', $rel) ? 0 : 1;
            $vis = 1;
            $st = '';
            if (preg_match('/style\s*=\s*(["\'])(.*?)\1/is', $lk[1], $sm)) $st = strtolower($sm[2]);
            if (preg_match('/(^|\s)hidden(\s*=|\s|$)/i', $lk[1])
                || preg_match('/aria-hidden\s*=\s*(["\']?)true\1/i', $lk[1])
                || ($st !== '' && preg_match('/display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?![.\d])|font-size\s*:\s*0|text-indent\s*:\s*-\d{3,}/', $st))
                || trim(strip_tags($lk[2])) === '' && !preg_match('/<img\b/i', $lk[2])) $vis = 0;
            if ($vis) {
                // hidden by an enclosing container: is any element with a hiding style/attr
                // still OPEN where the anchor sits? (stack over the raw prefix)
                $pos = strpos($html, $lk[0]);
                if ($pos !== false && la_hidden_container(substr($html, 0, $pos))) $vis = 0;
            }
            if ($f === 1 && $vis === 1) { $follow = 1; $visible = 1; break; }  // compliant anchor wins
            $follow = max($follow, $f); $visible = max($visible, $vis);
        }
    }
    return ['ok' => true, 'code' => $code, 'found' => $found, 'follow' => $follow,
            'visible' => $visible, 'indexable' => $indexable];
}

/** True when the HTML prefix leaves an element with a hiding style/attr still open. */
function la_hidden_container(string $prefix): bool {
    if (!preg_match_all('/<(\/?)([a-z][a-z0-9]*)\b([^>]*)>/is', $prefix, $tags, PREG_SET_ORDER)) return false;
    $stack = [];
    $void = ['br'=>1,'img'=>1,'hr'=>1,'meta'=>1,'link'=>1,'input'=>1,'source'=>1,'wbr'=>1,'area'=>1,'base'=>1,'col'=>1,'embed'=>1,'track'=>1,'param'=>1];
    foreach ($tags as $t) {
        $close = $t[1] === '/'; $name = strtolower($t[2]); $attrs = $t[3];
        if (isset($void[$name]) || str_ends_with(trim($attrs), '/')) continue;
        if ($close) {
            for ($i = count($stack) - 1; $i >= 0; $i--)
                if ($stack[$i][0] === $name) { array_splice($stack, $i); break; }
            continue;
        }
        $hid = false;
        if (preg_match('/style\s*=\s*(["\'])(.*?)\1/is', $attrs, $sm)
            && preg_match('/display\s*:\s*none|visibility\s*:\s*hidden/', strtolower($sm[2]))) $hid = true;
        if (!$hid && preg_match('/(^|\s)hidden(\s|=|>|$)/i', $attrs) && !preg_match('/aria-hidden/i', $attrs)) $hid = true;
        $stack[] = [$name, $hid];
    }
    foreach ($stack as $fr) if ($fr[1]) return true;
    return false;
}

/** dns.check — resolve a host to its public A/AAAA records (for cross-region integrity). */
function do_dns(array $payload): array {
    $host = (string)($payload['host'] ?? '');
    if ($host === '' || !preg_match('/^[a-z0-9.-]{1,253}$/i', $host)) return ['ok' => false, 'err' => 'bad_host'];
    $ips = [];
    foreach ((@dns_get_record($host, DNS_A | DNS_AAAA) ?: []) as $r) {
        $ip = $r['ip'] ?? ($r['ipv6'] ?? '');
        if ($ip !== '' && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE))
            $ips[$ip] = true;
    }
    if (!$ips) foreach (@gethostbynamel($host) ?: [] as $ip)
        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) $ips[$ip] = true;
    $ips = array_keys($ips);
    // #66 additional record types (informational — consensus stays on the A/AAAA IPs above)
    $mx = $ns = $txt = [];
    foreach ((@dns_get_record($host, DNS_MX) ?: []) as $r) if (!empty($r['target'])) $mx[] = strtolower((string)$r['target']);
    foreach ((@dns_get_record($host, DNS_NS) ?: []) as $r) if (!empty($r['target'])) $ns[] = strtolower((string)$r['target']);
    foreach ((@dns_get_record($host, DNS_TXT) ?: []) as $r) if (!empty($r['txt'])) $txt[] = substr((string)$r['txt'], 0, 180);
    sort($mx); sort($ns); sort($txt);
    return ['ok' => count($ips) > 0, 'ips' => $ips,
            'mx' => array_slice(array_values(array_unique($mx)), 0, 10),
            'ns' => array_slice(array_values(array_unique($ns)), 0, 10),
            'txt' => array_slice($txt, 0, 10), 'err' => $ips ? null : 'no_public_record'];
}

/** rss.watch — count entries in an RSS/Atom feed and fingerprint the latest item IDs, so the
 *  network agrees on "new entries appeared" the same way content.watch agrees on page text. */
function do_rss(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS     => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3,
        CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '',
    ]);
    $body = agent_exec($ch);
    $code = (int)(curl_getinfo($ch, CURLINFO_HTTP_CODE) ?: 0);
    $err  = $body === false ? curl_error($ch) : null;
    curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $body = (string)$body;
    $ids = [];
    if (preg_match_all('~<guid[^>]*>\s*(.*?)\s*</guid>~is', $body, $m)) $ids = $m[1];
    if (!$ids && preg_match_all('~<id[^>]*>\s*(.*?)\s*</id>~is', $body, $m)) $ids = $m[1];
    if (!$ids && preg_match_all('~<link[^>]*href=["\']([^"\']+)["\']~i', $body, $m)) $ids = $m[1];
    if (!$ids && preg_match_all('~<link>\s*(.*?)\s*</link>~is', $body, $m)) $ids = $m[1];
    $ids = array_values(array_unique(array_map('trim', $ids)));
    sort($ids);                                   // order-independent: devices agree on the SET
    $latest = array_slice($ids, 0, 50);
    $count  = count($ids);
    $hash   = $latest ? substr(hash('sha256', implode("\n", $latest)), 0, 16) : '';
    return ['ok' => $count > 0, 'code' => $code, 'count' => $count, 'hash' => $hash,
            'err' => $count ? null : 'no_items'];
}

/** port.check — is a TCP port reachable from here. */
function do_port(array $payload): array {
    $host = (string)($payload['host'] ?? '');
    $port = (int)($payload['port'] ?? 0);
    if ($host === '' || $port < 1 || $port > 65535) return ['ok' => false, 'err' => 'bad_input'];
    // resolve + refuse private targets even if asked
    $ips = @gethostbynamel($host) ?: (filter_var($host, FILTER_VALIDATE_IP) ? [$host] : []);
    $target = null;
    foreach ($ips as $ip) if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { $target = $ip; break; }
    if ($target === null) return ['ok' => false, 'err' => 'target_refused'];
    $t0 = microtime(true);
    $fp = @fsockopen($target, $port, $errno, $errstr, 8.0);   // audit: connect to the validated IP, not the hostname (no rebind)
    $open = $fp !== false;
    if ($fp) fclose($fp);
    return ['ok' => true, 'open' => $open ? 1 : 0, 'ms' => (int)round((microtime(true) - $t0) * 1000)];
}

/**
 * infer.embed — ON-DEVICE inference: a deterministic 32-bit SimHash content fingerprint
 * (locality-sensitive: near-duplicate pages get near-identical hashes) plus the dominant
 * script. Fully deterministic (crc32 per token) so every device with this agent version
 * produces the SAME output on the same content → quorum-verifiable. This is real edge-ML
 * feature extraction; it powers near-duplicate detection and language routing, and needs
 * no shipped model. The algorithm MUST stay byte-identical to the extension's.
 */
function do_infer(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '',
    ]);
    $body = agent_exec($ch);
    $i    = curl_getinfo($ch);
    $err  = $body === false ? curl_error($ch) : null;
    curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $text = mb_strtolower(normalize_text((string)$body));
    return ['ok' => true, 'code' => (int)($i['http_code'] ?? 0),
            'sim' => simhash32($text), 'lang' => detect_script($text)];
}

/** 32-bit SimHash over whitespace tokens; crc32 per token. Byte-identical to the extension. */
function simhash32(string $text): string {
    $v = array_fill(0, 32, 0);
    $toks = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
    if (!$toks) return '00000000';
    foreach ($toks as $tok) {
        $h = crc32($tok) & 0xFFFFFFFF;
        for ($b = 0; $b < 32; $b++) $v[$b] += (($h >> $b) & 1) ? 1 : -1;
    }
    $out = 0;
    for ($b = 0; $b < 32; $b++) if ($v[$b] > 0) $out |= (1 << $b);
    return str_pad(dechex($out & 0xFFFFFFFF), 8, '0', STR_PAD_LEFT);
}

/** Dominant script by Unicode range ratio: la|cy|gr|ar|he|cj|else 'la'. Deterministic. */
function detect_script(string $text): string {
    $counts = ['la' => 0, 'cy' => 0, 'gr' => 0, 'ar' => 0, 'he' => 0, 'cj' => 0];
    $len = mb_strlen($text);
    $cap = min($len, 4000);
    for ($i = 0; $i < $cap; $i++) {
        $cp = mb_ord(mb_substr($text, $i, 1)) ?: 0;
        if ($cp >= 0x0041 && $cp <= 0x024F) $counts['la']++;
        elseif ($cp >= 0x0400 && $cp <= 0x04FF) $counts['cy']++;
        elseif ($cp >= 0x0370 && $cp <= 0x03FF) $counts['gr']++;
        elseif ($cp >= 0x0600 && $cp <= 0x06FF) $counts['ar']++;
        elseif ($cp >= 0x0590 && $cp <= 0x05FF) $counts['he']++;
        elseif ($cp >= 0x3000 && $cp <= 0x9FFF) $counts['cj']++;
    }
    arsort($counts);
    $top = array_key_first($counts);
    return $counts[$top] > 0 ? $top : 'la';
}

/* ------------------------------------------------------------------ S1 distributed AI (Ollama)
 * infer.generate runs a whole quantized model on THIS device with strictly deterministic decoding
 * (fixed seed, temperature 0, top_k 1) so every honest holder returns byte-identical text and the
 * network can reach consensus on it. See PULSAR-DISTRIBUTED-AI.md S1 / lib/pulsar_infer.php. */
function ollama_base(): string {
    $c = is_array($GLOBALS['__pulsar_cfg'] ?? null) ? $GLOBALS['__pulsar_cfg'] : cfg_load();
    $b = trim((string)($c['ollama'] ?? ''));
    return $b !== '' ? rtrim($b, '/') : 'http://127.0.0.1:11434';
}
function ollama_available(): bool {
    $ch = curl_init(ollama_base() . '/api/tags');
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 2]);
    $o = curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
    return $code === 200 && is_string($o) && str_contains($o, 'models');
}
function ollama_has_model(string $name): bool {
    $ch = curl_init(ollama_base() . '/api/tags');
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 3]);
    $o = curl_exec($ch); curl_close($ch);
    if (!is_string($o)) return false;
    $j = json_decode($o, true) ?: [];
    foreach (($j['models'] ?? []) as $m)
        if (($m['name'] ?? '') === $name || ($m['model'] ?? '') === $name) return true;
    return false;
}
/** Normalise output before hashing. MUST stay byte-identical to server pi_norm() in lib/pulsar_infer.php. */
function pi_norm(string $s): string {
    $s = str_replace("\r\n", "\n", $s);
    $s = preg_replace('/[ \t]+/u', ' ', $s) ?? $s;
    $s = preg_replace('/\n{3,}/', "\n\n", $s) ?? $s;
    return trim($s);
}
/** infer.generate — run the model locally, deterministically. Returns {ok,gen,gtok,mdl,gh}. */
function do_infer_generate(array $payload): array {
    $model  = (string)($payload['model'] ?? '');
    $prompt = (string)($payload['prompt'] ?? '');
    if ($model === '' || $prompt === '') return ['ok' => false, 'err' => 'bad_input'];
    if (!ollama_has_model($model)) return ['ok' => false, 'err' => 'model_absent'];
    $params = is_array($payload['params'] ?? null) ? $payload['params'] : [];
    $opts = [
        'seed'           => (int)($params['seed'] ?? 42),
        'temperature'    => 0.0,
        'top_k'          => 1,
        'top_p'          => 1.0,
        'repeat_penalty' => (float)($params['repeat_penalty'] ?? 1.0),
        'num_predict'    => max(1, min(2048, (int)($params['num_predict'] ?? 512))),
    ];
    $body = json_encode(['model' => $model, 'prompt' => $prompt, 'stream' => false, 'options' => $opts], JSON_UNESCAPED_SLASHES);
    $ch = curl_init(ollama_base() . '/api/generate');
    curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body,
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => (int)max(30, min(900, (int)($payload['timeout_s'] ?? 300)))]);
    $out = curl_exec($ch); $err = $out === false ? curl_error($ch) : null; curl_close($ch);
    if ($err !== null) return ['ok' => false, 'err' => substr($err, 0, 120)];
    $j = json_decode((string)$out, true);
    if (!is_array($j) || !isset($j['response'])) return ['ok' => false, 'err' => 'no_response'];
    $text = (string)$j['response'];
    return ['ok' => true, 'gen' => $text, 'gtok' => (int)($j['eval_count'] ?? 0),
            'mdl' => $model, 'gh' => substr(hash('sha256', pi_norm($text)), 0, 16)];
}

/* S2 real runtime: this device can be a block HOLDER if it has a block runtime (ggml-rpc-server)
 * and the operator opted into serving. It then advertises caps.rpc_endpoint so the coordinator can
 * fold it into a pipeline chain (llama-server --rpc). See PULSAR-DISTRIBUTED-AI.md / work runtime-proto. */
function block_runtime_bin(): string {
    $c = is_array($GLOBALS['__pulsar_cfg'] ?? null) ? $GLOBALS['__pulsar_cfg'] : cfg_load();
    $bin = trim((string)($c['block_runtime_bin'] ?? ''));
    if ($bin !== '' && @is_executable($bin)) return $bin;
    if (function_exists('shell_exec'))
        foreach (['ggml-rpc-server', 'rpc-server'] as $n) {
            $pth = trim((string)@shell_exec('command -v ' . $n . ' 2>/dev/null'));
            if ($pth !== '' && @is_executable($pth)) return $pth;
        }
    return '';
}
function block_socat_bin(): string {
    // Prefer the socat shipped IN the block-runtime bundle (self-contained, mTLS-capable), then
    // fall back to a system socat. Empty means no socat -> mTLS cannot run.
    $rt = block_runtime_bin();
    if ($rt !== '') {
        $dir = dirname($rt);
        foreach (['pulsar-socat', 'socat', 'pulsar-socat.cmd'] as $n)
            if (@is_executable($dir . '/' . $n)) return $dir . '/' . $n;
    }
    if (function_exists('shell_exec')) {
        $x = trim((string)@shell_exec('command -v socat 2>/dev/null'));
        if ($x !== '' && @is_executable($x)) return $x;
    }
    return '';
}
function block_runtime_endpoint(): string {
    $c = is_array($GLOBALS['__pulsar_cfg'] ?? null) ? $GLOBALS['__pulsar_cfg'] : cfg_load();
    if (empty($c['block_serve'])) return '';                 // operator must opt in
    if (block_runtime_bin() === '') return '';
    $ep = trim((string)($c['rpc_endpoint'] ?? ''));
    return preg_match('~^[a-zA-Z0-9.\-]+:\d{2,5}$~', $ep) ? $ep : '';
}

/**
 * serp.check — the position of $domain in the public results for a query URL. This is the
 * ToS-grey capability: only ever dispatched when the operator has enabled it after a legal
 * review (server-side gate cfg pulsar_serp_enabled). The handler stays dormant until the
 * server hands out serp.check tasks, so shipping it changes nothing until that flip.
 * Returns {ok, position(0=not in top-100), code}.
 */
function do_serp(array $payload): array {
    $url = (string)($payload['query_url'] ?? '');
    $domain = strtolower(preg_replace('~^www\.~', '', (string)($payload['domain'] ?? '')) ?? '');
    if (!target_ok($url) || $domain === '') return ['ok' => false, 'err' => 'bad_input'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 20000))),
        CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_USERAGENT => AGENT_UA,
        CURLOPT_COOKIEFILE => '',
    ]);
    $body = agent_exec($ch);
    $i    = curl_getinfo($ch);
    $err  = $body === false ? curl_error($ch) : null;
    curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    // Extract result hosts in document order; find the tracked domain's first appearance.
    $pos = 0; $rank = 0; $seen = [];
    if (preg_match_all('~https?://([a-z0-9.-]+)~i', (string)$body, $mm)) {
        foreach ($mm[1] as $h) {
            $h = strtolower(preg_replace('~^www\.~', '', $h) ?? $h);
            // skip the search engine's own + common asset hosts
            if (preg_match('~(google|gstatic|googleusercontent|yandex|bing|schema\.org|w3\.org|youtube)~', $h)) continue;
            if (isset($seen[$h])) continue;
            $seen[$h] = true; $rank++;
            if (($h === $domain || str_ends_with($h, '.' . $domain)) && $pos === 0) { $pos = $rank; break; }
            if ($rank >= 100) break;
        }
    }
    return ['ok' => true, 'code' => (int)($i['http_code'] ?? 0), 'position' => $pos];
}

/** Navigate a dot-path (e.g. "data.items.0.name") into a decoded JSON value. */
function json_path($data, string $path) {
    if ($path === '') return $data;
    foreach (explode('.', $path) as $seg) {
        if (is_array($data) && array_key_exists($seg, $data)) $data = $data[$seg];
        else return null;
    }
    return $data;
}

/** Evaluate an assertion op deterministically (same logic as the extension). */
function assert_op(string $op, $actual, $expected): bool {
    return match ($op) {
        'exists'   => $actual !== null,
        'equals'   => (string)$actual === (string)$expected,
        'contains' => is_array($actual) ? in_array($expected, $actual, false)
                                        : str_contains((string)$actual, (string)$expected),
        'gt'       => is_numeric($actual) && (float)$actual > (float)$expected,
        'lt'       => is_numeric($actual) && (float)$actual < (float)$expected,
        default    => false,
    };
}

/** api.check — fetch a JSON endpoint and assert a dot-path value (multi-step monitoring). */
function do_apicheck(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '',
        CURLOPT_HTTPHEADER => ['Accept: application/json'],
    ]);
    $body = agent_exec($ch);
    $i    = curl_getinfo($ch);
    $err  = $body === false ? curl_error($ch) : null;
    curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $code = (int)($i['http_code'] ?? 0);
    $json = json_decode((string)$body, true);
    if ($json === null && trim((string)$body) !== 'null')
        return ['ok' => true, 'code' => $code, 'pass' => 0];   // not JSON = assertion fails, cleanly
    $op = (string)($payload['op'] ?? 'exists');
    $actual = json_path($json, (string)($payload['path'] ?? ''));
    $pass = assert_op($op, $actual, $payload['value'] ?? null);
    return ['ok' => true, 'code' => $code, 'pass' => $pass ? 1 : 0];
}

/** compress.audit — which encodings the origin actually serves, and the wire savings.
 * Four small fetches of the same URL with pinned Accept-Encoding values; the response is
 * measured on the wire (no auto-decompress), Content-Encoding decides whether the server
 * honored the request. Sizes are medianed server-side; the ckey is the encoding posture. */
function do_compress(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $tmo = min(30000, max(2000, (int)($payload['timeout_ms'] ?? 20000)));
    $fetch = function (string $enc) use ($url, $tmo): array {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => $tmo,
            CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA,
            CURLOPT_COOKIEFILE => '', CURLOPT_HEADER => true, CURLOPT_NOBODY => false,
            CURLOPT_HTTPHEADER => ['Accept-Encoding: ' . $enc],
        ]);
        $out = agent_exec($ch);
        $i   = curl_getinfo($ch);
        curl_close($ch);
        if ($out === false) return ['code' => 0, 'bytes' => 0, 'enc' => ''];
        $hdr = substr((string)$out, 0, (int)($i['header_size'] ?? 0));
        $ce  = preg_match('/^content-encoding:\s*([a-z0-9, -]+)/im', $hdr, $m) ? strtolower(trim($m[1])) : '';
        return ['code' => (int)($i['http_code'] ?? 0), 'bytes' => (int)($i['size_download'] ?? 0), 'enc' => $ce];
    };
    $raw = $fetch('identity');
    if ($raw['code'] < 200 || $raw['code'] >= 400 || $raw['bytes'] <= 0)
        return ['ok' => false, 'code' => $raw['code'], 'err' => 'no_baseline'];
    $res = ['ok' => true, 'code' => $raw['code'], 'raw_bytes' => $raw['bytes'],
            'gzip' => 0, 'br' => 0, 'zstd' => 0];
    $best = $raw['bytes'];
    foreach (['gzip', 'br', 'zstd'] as $enc) {
        $r = $fetch($enc);
        if ($r['code'] >= 200 && $r['code'] < 400 && str_contains($r['enc'], $enc) && $r['bytes'] > 0) {
            $res[$enc] = 1;
            if ($r['bytes'] < $best) $best = $r['bytes'];
        }
    }
    $res['best_bytes']  = $best;
    $res['savings_pct'] = (int)round(100 * (1 - $best / max(1, $raw['bytes'])));
    return $res;
}

/** ipv6.parity — does the site answer over IPv6 the same as over IPv4? A common silent
 * failure after AAAA records are added. Two HEAD fetches, one pinned to each family. */
function do_ipv6(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $probe = function (int $ipv) use ($url): array {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_NOBODY => true, CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT_MS => 15000, CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_USERAGENT => AGENT_UA, CURLOPT_IPRESOLVE => $ipv,
        ]);
        agent_exec($ch);
        $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err  = curl_errno($ch);
        curl_close($ch);
        return ['code' => $code, 'reach' => $err === 0 && $code > 0];
    };
    $v4 = $probe(CURL_IPRESOLVE_V4);
    $v6 = $probe(CURL_IPRESOLVE_V6);
    return ['ok' => true, 'v4' => (int)$v4['reach'], 'v6' => (int)$v6['reach'],
            'code' => $v4['code'] ?: $v6['code']];
}

/** mixed.scan — an https page pulling http:// subresources (broken padlock, blocked
 * assets). Counts insecure src/href/action references in the raw HTML. */
function do_mixed(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url) || !str_starts_with(strtolower($url), 'https://'))
        return ['ok' => false, 'err' => 'https_required'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '',
    ]);
    $body = agent_exec($ch);
    $i    = curl_getinfo($ch);
    $err  = $body === false ? curl_error($ch) : null;
    curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    // (src|href|action)="http://..." — active/passive mixed content. data:/#/https ignored.
    $n = preg_match_all('#\b(?:src|href|action)\s*=\s*["\']http://([^"\'\\s>]+)#i', (string)$body, $mm);
    // #32 mixed.deep: the actual insecure subresource URLs (informational — NOT in the ckey).
    $items = array_slice(array_values(array_unique(array_map(
        fn($u) => 'http://' . substr((string)$u, 0, 180), $mm[1] ?? []))), 0, 20);
    return ['ok' => true, 'code' => (int)($i['http_code'] ?? 0), 'mixed' => (int)$n, 'mixed_items' => $items];
}

/** redirect.chain — how many hops before the final page, and does it loop? Follows up to
 * 10 redirects manually so the count is exact and a loop is detectable. */
function do_redirect(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $seen = []; $hops = 0; $cur = $url; $loop = 0;
    for ($i = 0; $i < 10; $i++) {
        if (!target_ok($cur)) { $loop = 0; break; }
        $seen[$cur] = ($seen[$cur] ?? 0) + 1;
        if ($seen[$cur] > 1) { $loop = 1; break; }
        $ch = curl_init($cur);
        curl_setopt_array($ch, [
            CURLOPT_NOBODY => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => 12000,
            CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA,
        ]);
        agent_exec($ch);
        $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $loc  = (string)curl_getinfo($ch, CURLINFO_REDIRECT_URL);
        curl_close($ch);
        if ($code >= 300 && $code < 400 && $loc !== '') { $hops++; $cur = $loc; continue; }
        break;
    }
    /* F29 Where the redirect actually LANDED. Hops and loop alone cannot answer the question the
     * item is about — www↔apex and http→https canonicalisation: "1 hop" reads identically whether
     * the site upgraded to HTTPS, moved to www, or handed the visitor to a parked domain. The
     * classification is a closed vocabulary so a mixed fleet still agrees on the words. */
    $from = parse_url($url); $to = parse_url($cur);
    $fh = strtolower((string)($from['host'] ?? '')); $th = strtolower((string)($to['host'] ?? ''));
    $bare = fn(string $h): string => str_starts_with($h, 'www.') ? substr($h, 4) : $h;
    if ($fh === '' || $th === '')      $move = 'unknown';
    elseif ($fh === $th)               $move = 'same_host';
    elseif ($bare($fh) !== $bare($th)) $move = 'other_host';
    elseif (str_starts_with($th, 'www.')) $move = 'to_www';
    else                                  $move = 'to_apex';
    return ['ok' => true, 'hops' => $hops, 'loop' => $loop,
            'https_final' => (int)(strtolower((string)($to['scheme'] ?? '')) === 'https'),
            'canon' => $move];
}

/** smtp.check — is a mail host's SMTP port answering with a 220 banner? No mail is sent.
 * Raw socket (like port.check) — NOT run on the server node (would bypass the proxy). */
function do_smtp(array $payload): array {
    $host = (string)($payload['host'] ?? '');
    $port = (int)($payload['port'] ?? 25);
    if ($host === '' || !preg_match('/^[a-z0-9.-]+$/i', $host) || $port < 1 || $port > 65535)
        return ['ok' => false, 'err' => 'bad_host'];
    $ips = @gethostbynamel($host) ?: [];
    foreach ($ips as $ip)
        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE))
            return ['ok' => false, 'err' => 'target_refused'];
    if (!$ips) return ['ok' => false, 'err' => 'no_dns'];
    $fp = @fsockopen($ips[0], $port, $e, $es, 8.0);   // audit: connect to the validated IP, not the hostname (no rebind)
    if (!$fp) return ['ok' => true, 'up' => 0, 'banner' => 0];
    stream_set_timeout($fp, 8);
    $line = (string)fgets($fp, 256);
    fclose($fp);
    return ['ok' => true, 'up' => 1, 'banner' => (int)str_starts_with(trim($line), '220')];
}

/** robots.diff — fetch /robots.txt and hash it. Different hashes across vantage points mean
 * the site serves different robots to different geos/UAs (cloaking or geo-fencing) — the
 * quorum flags the inconsistency the way dns.check does. */
function do_robots(array $payload): array {
    $base = (string)($payload['url'] ?? '');
    $p = parse_url($base);
    if (!$p || empty($p['host'])) return ['ok' => false, 'err' => 'target_refused'];
    $url = ($p['scheme'] ?? 'https') . '://' . $p['host'] . '/robots.txt';
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => 12000,
        CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '',
    ]);
    $body = agent_exec($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err  = $body === false ? curl_error($ch) : null;
    curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    // normalize line endings + trailing ws so trivial diffs do not fragment the quorum
    $norm = preg_replace('/[ \t]+$/m', '', str_replace("\r\n", "\n", (string)$body)) ?? (string)$body;
    return ['ok' => true, 'code' => $code, 'robots' => substr(hash('sha256', trim($norm)), 0, 16)];
}

/** captcha.rate — does a plain request get the real page or an anti-bot challenge? Reports a
 * boolean; across many real networks the consensus shows how often visitors are challenged. */
function do_captcha(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true,
        CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '',
    ]);
    $out = agent_exec($ch);
    $i   = curl_getinfo($ch);
    $err = $out === false ? curl_error($ch) : null;
    curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $code = (int)($i['http_code'] ?? 0);
    $hlen = (int)($i['header_size'] ?? 0);
    $head = strtolower(substr((string)$out, 0, $hlen));
    $bodyLc = strtolower(substr((string)$out, $hlen, 4000));
    // signatures: CF challenge (503/429 + cf-mitigated / __cf_chl), hCaptcha/reCAPTCHA markers,
    // "just a moment" interstitials, generic "verify you are human".
    $sig = (str_contains($head, 'cf-mitigated') || str_contains($head, 'cf-chl-bypass')
        || str_contains($bodyLc, '__cf_chl') || str_contains($bodyLc, 'just a moment')
        || str_contains($bodyLc, 'hcaptcha') || str_contains($bodyLc, 'g-recaptcha')
        || str_contains($bodyLc, 'verify you are human') || $code === 429);
    return ['ok' => true, 'code' => $code, 'challenged' => (int)$sig];
}

/** asset.integrity — supply-chain posture: of the external <script src> a page loads, how many
 * carry a Subresource-Integrity (integrity=) hash. Unprotected third-party scripts are the
 * classic supply-chain attack surface. Deterministic (counts of the same HTML) so quorum agrees. */
function do_integrity(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '',
    ]);
    $body = agent_exec($ch);
    $i    = curl_getinfo($ch);
    $err  = $body === false ? curl_error($ch) : null;
    curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?: ''));
    $ext = 0; $prot = 0;
    if (preg_match_all('/<script\b[^>]*\bsrc\s*=\s*["\']([^"\']+)["\'][^>]*>/i', (string)$body, $mm, PREG_SET_ORDER)) {
        foreach ($mm as $tag) {
            $src = $tag[1];
            $sh  = strtolower((string)(parse_url($src, PHP_URL_HOST) ?: ''));
            // external = a different host (protocol-relative/absolute); same-host scripts are yours
            if ($sh === '' || $sh === $host) continue;
            $ext++;
            if (preg_match('/\bintegrity\s*=\s*["\']/i', $tag[0])) $prot++;
        }
    }
    return ['ok' => true, 'code' => (int)($i['http_code'] ?? 0), 'ext_scripts' => $ext, 'protected' => $prot];
}

/** cdn.pop — which CDN (if any) fronts the origin, from response headers. Consensus is on the
 * vendor; the per-country POP breakdown is a coverage-style read on the raw pop value. (#7) */
function do_cdn(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [CURLOPT_NOBODY => true, CURLOPT_HEADER => true, CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS => 15000, CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA]);
    $out = agent_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err = $out === false ? curl_error($ch) : null; curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $h = strtolower((string)$out);
    $vendor = 'none'; $pop = '';
    if (str_contains($h, 'cf-ray')) { $vendor = 'cloudflare';
        if (preg_match('/cf-ray:\s*[0-9a-f]+-([a-z]{3})/i', (string)$out, $m)) $pop = strtoupper($m[1]); }
    elseif (str_contains($h, 'x-served-by') || str_contains($h, 'fastly')) { $vendor = 'fastly';
        if (preg_match('/x-served-by:\s*[^\r\n]*?([a-z]{3}\d*)-/i', (string)$out, $m)) $pop = strtoupper($m[1]); }
    elseif (str_contains($h, 'x-amz-cf-pop')) { $vendor = 'cloudfront';
        if (preg_match('/x-amz-cf-pop:\s*([a-z]{3})/i', (string)$out, $m)) $pop = strtoupper($m[1]); }
    elseif (str_contains($h, 'x-akamai') || str_contains($h, 'akamai')) $vendor = 'akamai';
    elseif (str_contains($h, 'x-cache') && str_contains($h, 'varnish')) $vendor = 'varnish';
    return ['ok' => true, 'code' => $code, 'cdn' => $vendor, 'pop' => substr($pop, 0, 8)];
}

/** geo.block — does this vantage get blocked (403/451 or an interstitial)? The per-country map
 * of WHERE a site is geo-fenced is a coverage read; consensus is on the boolean. (#10) */
function do_geoblock(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => 15000,
        CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '']);
    $body = agent_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err = $body === false ? curl_error($ch) : null; curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $lc = strtolower(substr((string)$body, 0, 3000));
    $blocked = in_array($code, [403, 451], true)
        || str_contains($lc, 'not available in your country')
        || str_contains($lc, 'access denied in your region');
    return ['ok' => true, 'code' => $code, 'blocked' => (int)$blocked];
}

/** journey.check — a multi-step HTTP journey: fetch each step URL and confirm its text; report
 * the first failing step (0 = all passed). Steps come as payload.steps = [[url, expect], ...]. (#60) */
function do_journey(array $payload): array {
    $steps = is_array($payload['steps'] ?? null) ? $payload['steps'] : [];
    if (!$steps) return ['ok' => false, 'err' => 'no_steps'];
    $n = 0;
    foreach (array_slice($steps, 0, 8) as $i => $st) {
        $u = (string)($st['url'] ?? ''); $expect = (string)($st['expect'] ?? '');
        if (!target_ok($u)) return ['ok' => true, 'steps' => count($steps), 'failed_step' => $i + 1];
        $ch = curl_init($u);
        curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => 15000,
            CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '']);
        $b = agent_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
        $pass = $b !== false && $code >= 200 && $code < 400 && ($expect === '' || stripos((string)$b, $expect) !== false);
        if (!$pass) return ['ok' => true, 'steps' => count($steps), 'failed_step' => $i + 1];
        $n++;
    }
    return ['ok' => true, 'steps' => count($steps), 'failed_step' => 0];
}

/** traceroute.map — network path hop count to the target, via the system traceroute (best-effort;
 * needs the traceroute binary and is skipped where unavailable or behind a proxy). (#2) */
function do_trace(array $payload): array {
    $host = (string)($payload['host'] ?? parse_url((string)($payload['url'] ?? ''), PHP_URL_HOST));
    if ($host === '' || !preg_match('/^[a-z0-9.-]+$/i', $host)) return ['ok' => false, 'err' => 'bad_host'];
    $ips = @gethostbynamel($host) ?: [];
    foreach ($ips as $ip) if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return ['ok' => false, 'err' => 'target_refused'];
    if (!$ips) return ['ok' => false, 'err' => 'no_dns'];
    if (!function_exists('exec')) return ['ok' => false, 'err' => 'exec_unavailable'];
    $bin = PHP_OS_FAMILY === 'Windows' ? 'tracert -h 20 -w 800' : 'traceroute -m 20 -w 1 -q 1 -n';
    $out = []; $rc = 1; @exec($bin . ' ' . escapeshellarg($host) . ' 2>/dev/null', $out, $rc);
    if ($rc !== 0 && !$out) return ['ok' => false, 'err' => 'traceroute_unavailable'];
    // count numbered hop lines that actually resolved (not just '*')
    $hops = 0;
    foreach ($out as $line) if (preg_match('/^\s*\d+\s+/', $line) && !preg_match('/^\s*\d+\s+\*\s+\*\s+\*/', $line)) $hops++;
    return ['ok' => true, 'hops' => min(30, $hops)];
}

/** dns.dnssec — is the domain's DNS cryptographically signed (DNSSEC)? Asks a validating DoH
 * resolver and reads the Authenticated-Data flag. Consensus is on signed/unsigned. (#5) */
function do_dnssec(array $payload): array {
    $host = (string)($payload['host'] ?? parse_url((string)($payload['url'] ?? ''), PHP_URL_HOST));
    if ($host === '' || !preg_match('/^[a-z0-9.-]{1,253}$/i', $host)) return ['ok' => false, 'err' => 'bad_host'];
    $ch = curl_init('https://dns.google/resolve?name=' . urlencode($host) . '&type=A&do=1');
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => 12000,
        CURLOPT_USERAGENT => AGENT_UA, CURLOPT_HTTPHEADER => ['Accept: application/dns-json']]);
    $out = agent_exec($ch); $err = $out === false ? curl_error($ch) : null; curl_close($ch);
    if ($err !== null) return ['ok' => false, 'err' => substr($err, 0, 120)];
    $j = json_decode((string)$out, true);
    if (!is_array($j) || !isset($j['Status'])) return ['ok' => false, 'err' => 'bad_resolver_reply'];
    // AD = the resolver cryptographically validated the answer via DNSSEC.
    return ['ok' => true, 'signed' => (int)!empty($j['AD'])];
}

/** app.rank — an app's position in a country's App Store top chart, from the public RSS feed.
 * The chart is the same for every device in a country, so quorum agrees on the rank. (#12) */
function do_apprank(array $payload): array {
    $track = (int)($payload['track_id'] ?? 0);
    $cc    = strtolower(preg_replace('/[^a-z]/i', '', (string)($payload['country'] ?? 'us')) ?: 'us');
    $chart = in_array(($payload['chart'] ?? ''), ['topfreeapplications', 'toppaidapplications', 'topgrossingapplications'], true)
           ? (string)$payload['chart'] : 'topfreeapplications';
    if ($track <= 0 || strlen($cc) !== 2) return ['ok' => false, 'err' => 'bad_params'];
    $url = 'https://itunes.apple.com/' . $cc . '/rss/' . $chart . '/limit=200/json';
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => 15000,
        CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 2, CURLOPT_USERAGENT => AGENT_UA]);
    $out = agent_exec($ch); $err = $out === false ? curl_error($ch) : null; curl_close($ch);
    if ($err !== null) return ['ok' => false, 'err' => substr($err, 0, 120)];
    $j = json_decode((string)$out, true);
    $entries = $j['feed']['entry'] ?? null;
    if (!is_array($entries)) return ['ok' => true, 'rank' => 0, 'found' => 0];   // empty/blocked chart
    $rank = 0;
    foreach (array_values($entries) as $i => $e) {
        $id = (int)($e['id']['attributes']['im:id'] ?? 0);
        if ($id === $track) { $rank = $i + 1; break; }
    }
    return ['ok' => true, 'rank' => $rank, 'found' => (int)($rank > 0)];
}

/** meta.audit — page-level SEO/security meta posture from ONE fetch: structured data
 *  (JSON-LD validity), Open Graph / Twitter card, favicon/manifest presence, HSTS(+preload),
 *  and negotiated HTTP version. Each is a discrete consensus
 *  sub-verdicts — every flag is deterministic for a given response, so quorum forms on the
 *  exact posture (like ssl.audit's hardening bits, not a flaky byte count). */
function do_meta(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url) || !str_starts_with(strtolower($url), 'https://'))
        return ['ok' => false, 'err' => 'target_refused'];
    if (!empty($payload['respect_robots']) && !robots_allows($url)) return ['ok' => false, 'err' => 'robots_disallow'];   // #246
    $hdr = '';
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '',
        CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
        CURLOPT_HEADERFUNCTION => function ($c, $line) use (&$hdr) { $hdr .= $line; return strlen($line); },
    ]);
    agent_cap_curl($ch);
    $body = agent_exec($ch);
    $i    = curl_getinfo($ch);
    $err  = $body === false ? curl_error($ch) : null;
    curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $html = (string)$body;
    // #37 structured data: at least one <script type="application/ld+json"> that parses as JSON.
    $ld = 0;
    if (preg_match_all('~<script[^>]*type\s*=\s*["\']application/ld\+json["\'][^>]*>(.*?)</script>~is', $html, $lm)) {
        foreach ($lm[1] as $blk) { json_decode(trim($blk)); if (json_last_error() === JSON_ERROR_NONE) { $ld = 1; break; } }
    }
    // #38 open graph / twitter card presence (social preview).
    $og = (int)(bool)(preg_match('~<meta[^>]+property\s*=\s*["\']og:(?:title|image|type|url)["\']~i', $html)
             || preg_match('~<meta[^>]+name\s*=\s*["\']twitter:card["\']~i', $html));
    // #39 favicon / apple-touch / manifest presence.
    $icon = (int)(bool)(preg_match('~<link[^>]+rel\s*=\s*["\'][^"\']*(?:apple-touch-icon|shortcut icon|icon)[^"\']*["\']~i', $html)
               || preg_match('~<link[^>]+rel\s*=\s*["\']manifest["\']~i', $html));
    // #42 HSTS + preload from the response header.
    $hsts = 0; $pre = 0;
    if (preg_match('~^strict-transport-security:\s*(.+)$~im', $hdr, $hm)) {
        $hsts = 1;
        if (stripos($hm[1], 'preload') !== false) $pre = 1;
    }
    // #40 negotiated HTTP version (curl: 3 = HTTP/2, >=30 = HTTP/3 across builds).
    $hv = (int)($i['http_version'] ?? 0);
    $h2 = (int)($hv === 3);
    $h3 = (int)($hv >= 30);
    return ['ok' => true, 'code' => (int)($i['http_code'] ?? 0),
            'ld' => $ld, 'og' => $og, 'icon' => $icon,
            'hsts' => $hsts, 'hsts_preload' => $pre, 'h2' => $h2, 'h3' => $h3];
}

/** email.audit — mail-security posture of a domain from DNS alone (#21 SPF/DMARC, #44 MX):
 *  MX presence/count, SPF record + strictness (-all), DMARC record + policy. Deterministic per
 *  domain, so quorum forms on the exact posture. DKIM needs a per-provider selector we cannot
 *  enumerate, so it stays out of the consensus key. */
function do_email(array $payload): array {
    $host = (string)($payload['host'] ?? $payload['domain'] ?? '');
    if ($host === '' || !preg_match('/^[a-z0-9.-]{1,253}$/i', $host)) return ['ok' => false, 'err' => 'bad_host'];
    $mx = 0;
    foreach ((@dns_get_record($host, DNS_MX) ?: []) as $r) if (!empty($r['target'])) $mx++;
    $spf = 0; $spfStrict = 0;
    foreach ((@dns_get_record($host, DNS_TXT) ?: []) as $r) {
        $t = strtolower(trim((string)($r['txt'] ?? '')));
        if (str_starts_with($t, 'v=spf1')) { $spf = 1; if (str_contains($t, '-all')) $spfStrict = 1; break; }
    }
    $dmarc = 0; $dmarcPol = 0;
    foreach ((@dns_get_record('_dmarc.' . $host, DNS_TXT) ?: []) as $r) {
        $t = strtolower((string)($r['txt'] ?? ''));
        if (str_contains($t, 'v=dmarc1')) {
            $dmarc = 1;
            if (preg_match('/p\s*=\s*(none|quarantine|reject)/', $t, $m))
                $dmarcPol = ['none' => 0, 'quarantine' => 1, 'reject' => 2][$m[1]];
            break;
        }
    }
    return ['ok' => true, 'mx_count' => $mx, 'spf' => $spf, 'spf_strict' => $spfStrict,
            'dmarc' => $dmarc, 'dmarc_policy' => $dmarcPol];
}

/** favicon.clone — fetch a site's favicon and fingerprint it (#27). Phishing/clone sites reuse
 *  the brand's exact favicon, so an identical hash across UNRELATED domains is the tell (that
 *  cross-domain match is a server-side read over the collected hashes). The device just reports
 *  the hash; the icon fetch is pinned to the page's OWN host + https (no SSRF via a foreign href). */
function do_favicon(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url) || !str_starts_with(strtolower($url), 'https://')) return ['ok' => false, 'err' => 'target_refused'];
    if (!empty($payload['respect_robots']) && !robots_allows($url)) return ['ok' => false, 'err' => 'robots_disallow'];   // #246
    $host = (string)(parse_url($url, PHP_URL_HOST) ?: '');
    if ($host === '') return ['ok' => false, 'err' => 'bad_host'];
    $to = min(20000, max(2000, (int)($payload['timeout_ms'] ?? 12000)));
    $get = function (string $u, bool $binary) use ($to) {
        $ch = curl_init($u);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => $to,
            CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3,
            CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '',
            CURLOPT_BUFFERSIZE => 16384,
        ]);
        $b = agent_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err = $b === false; curl_close($ch);
        return $err ? [null, 0] : [(string)$b, $code];
    };
    // declared <link rel="...icon..." href>, resolved but pinned to same host + https.
    $icon = "https://$host/favicon.ico";
    [$html, $pc] = $get($url, false);
    if ($html !== null && preg_match('~<link[^>]+rel\s*=\s*["\'][^"\']*icon[^"\']*["\'][^>]*href\s*=\s*["\']([^"\']+)["\']~i', $html, $m)) {
        $href = trim($m[1]);
        if (str_starts_with($href, '//')) $href = 'https:' . $href;
        elseif (str_starts_with($href, '/')) $href = "https://$host" . $href;
        elseif (!preg_match('~^https?://~i', $href)) $href = "https://$host/" . ltrim($href, '/');
        $hu = parse_url($href);
        if (strtolower((string)($hu['scheme'] ?? '')) === 'https' && strtolower((string)($hu['host'] ?? '')) === strtolower($host))
            $icon = $href;
    }
    [$bytes, $code] = $get($icon, true);
    if ($bytes === null || $bytes === '' || strlen($bytes) > 262144)
        return ['ok' => true, 'code' => $code, 'found' => 0, 'hash' => '', 'bytes' => 0];
    return ['ok' => true, 'code' => $code, 'found' => 1,
            'hash' => substr(hash('sha256', $bytes), 0, 16), 'bytes' => strlen($bytes)];
}

/** blacklist.check — distributed threat-feed confirmation (#19). The device resolves the host to
 *  its public IPv4 and independently queries a handful of public DNSBLs (reversed-octet A lookup;
 *  a 127.0.0.x answer = listed). Consensus is on the BOOLEAN "flagged somewhere" — per-DNSBL
 *  answers vary by vantage/rate-limit, so the raw count is informational (medianed), not the key.
 *  Accepts an explicit ip for self-tests. */
function do_blacklist(array $payload): array {
    $ip = (string)($payload['ip'] ?? '');
    if ($ip === '') {
        $host = (string)($payload['host'] ?? '');
        if ($host === '' || !preg_match('/^[a-z0-9.-]{1,253}$/i', $host)) return ['ok' => false, 'err' => 'bad_host'];
        foreach (@gethostbynamel($host) ?: [] as $a)
            if (filter_var($a, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { $ip = $a; break; }
    }
    if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return ['ok' => false, 'err' => 'no_ipv4'];
    $rev = implode('.', array_reverse(explode('.', $ip)));
    $zones = ['zen.spamhaus.org', 'bl.spamcop.net', 'b.barracudacentral.org', 'dnsbl.sorbs.net'];
    $listed = 0; $checked = 0;
    foreach ($zones as $z) {
        $checked++;
        foreach (@dns_get_record($rev . '.' . $z, DNS_A) ?: [] as $rec)
            if (!empty($rec['ip']) && str_starts_with((string)$rec['ip'], '127.')) { $listed++; break; }
    }
    return ['ok' => true, 'listed' => $listed, 'checked' => $checked, 'flagged' => (int)($listed > 0)];
}

/** price.extract — pull a product price from STRUCTURED sources only (#22): JSON-LD Offer.price,
 *  OpenGraph/product meta, microdata itemprop=price. No regex-guessing the visible DOM (that varies
 *  by locale/AB-test and would poison consensus). Consensus is on "a price was found + currency";
 *  the value itself is stored in minor units and medianed, so a transient AB price can't split
 *  quorum. Feeds Watchtower price-drop (#46). */
function do_price(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url) || !str_starts_with(strtolower($url), 'https://')) return ['ok' => false, 'err' => 'target_refused'];
    if (!empty($payload['respect_robots']) && !robots_allows($url)) return ['ok' => false, 'err' => 'robots_disallow'];   // #246
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '',
    ]);
    $body = agent_exec($ch); $i = curl_getinfo($ch); $err = $body === false ? curl_error($ch) : null; curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $html = (string)$body;
    $norm = function (string $s): ?int {
        $s = preg_replace('/[^0-9.,]/', '', trim($s));
        if ($s === '' || $s === null) return null;
        if (strpos($s, ',') !== false && strpos($s, '.') !== false) {
            if (strrpos($s, ',') > strrpos($s, '.')) { $s = str_replace('.', '', $s); $s = str_replace(',', '.', $s); }
            else { $s = str_replace(',', '', $s); }
        } elseif (strpos($s, ',') !== false) {
            $parts = explode(',', $s);
            $s = (count($parts) === 2 && strlen(end($parts)) === 2) ? str_replace(',', '.', $s) : str_replace(',', '', $s);
        }
        if (!is_numeric($s)) return null;
        $f = (float)$s;
        return ($f < 0 || $f > 100000000) ? null : (int)round($f * 100);
    };
    $cents = null; $cur = '';
    if (preg_match_all('~<script[^>]*application/ld\+json[^>]*>(.*?)</script>~is', $html, $lm)) {
        foreach ($lm[1] as $blk) {
            $j = json_decode(trim($blk), true); if (!is_array($j)) continue;
            $stack = [$j]; $guard = 0;
            while ($stack && $guard < 4000) {
                $node = array_pop($stack); $guard++;
                if (!is_array($node)) continue;
                foreach (['price', 'lowPrice'] as $pk) {
                    if (isset($node[$pk]) && (is_string($node[$pk]) || is_numeric($node[$pk]))) {
                        $c = $norm((string)$node[$pk]);
                        if ($c !== null) { $cents = $c; if (isset($node['priceCurrency']) && is_string($node['priceCurrency'])) $cur = strtoupper(substr($node['priceCurrency'], 0, 3)); break 2; }
                    }
                }
                foreach ($node as $v) if (is_array($v)) $stack[] = $v;
            }
            if ($cents !== null) break;
        }
    }
    if ($cents === null && preg_match('~<meta[^>]+(?:property|name)\s*=\s*["\'](?:product:price:amount|og:price:amount)["\'][^>]+content\s*=\s*["\']([^"\']+)["\']~i', $html, $m)) {
        $cents = $norm($m[1]);
        if (preg_match('~<meta[^>]+(?:property|name)\s*=\s*["\'](?:product:price:currency|og:price:currency)["\'][^>]+content\s*=\s*["\']([A-Za-z]{3})["\']~i', $html, $mc)) $cur = strtoupper($mc[1]);
    }
    if ($cents === null && preg_match('~itemprop\s*=\s*["\']price["\'][^>]*content\s*=\s*["\']([^"\']+)["\']~i', $html, $m)) $cents = $norm($m[1]);
    $found = $cents !== null ? 1 : 0;
    return ['ok' => true, 'code' => (int)($i['http_code'] ?? 0), 'found' => $found,
            'price_cents' => $found ? $cents : 0, 'currency' => substr($cur, 0, 3)];
}

/** spelling.scan — page quality: unrendered template placeholders / leaks AND common misspellings
 *  (#36). Both deterministic per page, so devices agree. Leak detection (the high-value half) flags
 *  {{var}}, {% %}, ${var}, printf %s/%d, "undefined"/"NaN"/"[object Object]"/"Lorem ipsum" left in
 *  VISIBLE text — the classic "template didn't render" bug. Spelling uses a small embedded common-
 *  misspelling list (English), word-boundary matched. respect_robots honoured. */
function do_spelling(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    if (!empty($payload['respect_robots']) && !robots_allows($url)) return ['ok' => false, 'err' => 'robots_disallow'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '']);
    $body = agent_exec($ch); $i = curl_getinfo($ch); $err = $body === false ? curl_error($ch) : null; curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $html = (string)$body;
    $t = preg_replace('~<(script|style|noscript|template)\b[^>]*>.*?</\1>~is', ' ', $html);
    $t = html_entity_decode(strip_tags((string)$t), ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $leaks = 0; $samples = [];
    foreach (['~\{\{[^}]{1,40}\}\}~', '~\{%[^%]{1,40}%\}~', '~\$\{[^}]{1,40}\}~',
              '~%[sd]\b~', '~\bundefined\b~', '~\bNaN\b~', '~\[object Object\]~', '~\bLorem ipsum~i'] as $re) {
        if (preg_match_all($re, $t, $mm)) { $leaks += count($mm[0]); foreach (array_slice($mm[0], 0, 2) as $s) $samples[] = trim(mb_substr($s, 0, 40)); }
    }
    static $bad = ['teh','recieve','seperate','occured','definately','accomodate','wich','freind',
        'beleive','goverment','enviroment','neccessary','occassion','untill','wierd','tommorow',
        'embarass','existance','maintainance','publically','succesful','begining','calender','concious'];
    $mis = 0; $misWords = [];
    if (preg_match_all('~\b([A-Za-z]{3,})\b~', $t, $wm)) {
        $set = array_flip($bad);
        foreach ($wm[1] as $w) { $lw = strtolower($w); if (isset($set[$lw])) { $mis++; $misWords[$lw] = true; } }
    }
    foreach (array_slice(array_keys($misWords), 0, 3) as $w) $samples[] = $w;
    return ['ok' => true, 'code' => (int)($i['http_code'] ?? 0),
            'leaks' => $leaks, 'misspelled' => $mis,
            'samples' => array_values(array_slice(array_unique($samples), 0, 6))];
}

/** sitemap.diff — fingerprint a sitemap's URL SET so the network agrees on "the set of published
 *  URLs", the same way rss.watch agrees on feed items (#29). A change in the hash = URLs added/removed.
 *  Accepts a sitemap URL or a site root (then /sitemap.xml). Follows a sitemap index one level. */
function do_sitemapdiff(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $sm = preg_match('~sitemap[^/]*\.xml~i', $url) ? $url : rtrim($url, '/') . '/sitemap.xml';
    $fetch = function (string $u) {
        $ch = curl_init($u);
        curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15,
            CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_USERAGENT => AGENT_UA]);
        $b = agent_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
        return [$b === false ? '' : (string)$b, $code];
    };
    [$body, $code] = $fetch($sm);
    if ($code !== 200 || $body === '') return ['ok' => false, 'code' => $code, 'err' => 'no_sitemap'];
    // a sitemap index points at child sitemaps; pull one level (bounded) and union their <loc>s.
    if (stripos($body, '<sitemapindex') !== false && preg_match_all('~<loc>\s*(.*?)\s*</loc>~is', $body, $im)) {
        $locs = [];
        foreach (array_slice($im[1], 0, 20) as $child) {
            [$cb, $cc] = $fetch(trim($child));
            if ($cc === 200 && preg_match_all('~<loc>\s*(.*?)\s*</loc>~is', $cb, $cm)) foreach ($cm[1] as $u) { $locs[] = trim($u); if (count($locs) >= 50000) break 2; }   // audit: bound union memory
        }
    } elseif (preg_match_all('~<loc>\s*(.*?)\s*</loc>~is', $body, $m)) {
        $locs = array_slice(array_map('trim', $m[1]), 0, 50000);   // audit: bound union memory
    } else {
        $locs = [];
    }
    $locs = array_values(array_unique($locs)); sort($locs);
    return ['ok' => true, 'code' => 200, 'count' => count($locs),
            'hash' => substr(hash('sha256', implode("\n", $locs)), 0, 16)];
}

/** unshorten — expand a short link to its final destination (#69). Follows redirects manually,
 *  re-validating each hop is public (no SSRF), and reports the final host — deterministic, so the
 *  network agrees on where a short link REALLY goes (phishing / affiliate-cloaking signal). */
/**
 * link.location: куда УКАЗЫВАЕТ перенаправление, без обращения к конечному сайту.
 *
 * Отличие от do_unshorten() в одном шаге, и он решает дело: тот идёт по цепочке до конца и
 * потому зависит от того, ответит ли конечный сайт. Здесь читаются только заголовки, и как
 * только хост отличается от исходного — это и есть ответ. Заведён 30.08.2026, когда треть
 * нераскрытых обменников перестала отвечать жилым устройствам: адрес был назван заголовком
 * ещё до того, как до сайта дошло дело.
 *
 * Хост не сменился — это НЕ ответ (`ok = false`): отдать исходный хост значило бы получить
 * согласие сети на пустом месте.
 */
function do_redirloc(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $origin = strtolower((string)(parse_url($url, PHP_URL_HOST) ?: ''));
    $to = min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000)));
    $t0 = microtime(true);
    $hops = 0; $code = 0; $cur = $url;
    for ($i = 0; $i < 3; $i++) {
        $ch = curl_init($cur);
        curl_setopt_array($ch, [CURLOPT_NOBODY => true, CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT_MS => $to, CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '']);
        agent_exec($ch);
        $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        /* Тот же способ, что у do_unshorten(): libcurl отдаёт УЖЕ разрешённый абсолютный
           адрес, поэтому относительный Location разбирать руками не нужно — и оба вида
           точно одинаково понимают, куда ведёт ссылка. */
        $loc  = (string)curl_getinfo($ch, CURLINFO_REDIRECT_URL);
        curl_close($ch);
        if ($code < 300 || $code > 399 || $loc === '') break;
        $cur = $loc; $hops++;
        $h = strtolower((string)(parse_url($cur, PHP_URL_HOST) ?: ''));
        if ($h !== $origin) break;            // хост сменился — это и есть ответ
        if (!target_ok($cur)) break;
    }
    $host  = strtolower((string)(parse_url($cur, PHP_URL_HOST) ?: ''));
    $moved = $host !== '' && $host !== $origin;
    return ['ok' => $moved, 'code' => $code,
            'loc_host' => $moved ? $host : '', 'loc_url' => $moved ? mb_substr($cur, 0, 300) : '',
            'hops' => $hops, 'bytes' => 0, 'total_ms' => (int)round((microtime(true) - $t0) * 1000),
            'err' => $moved ? null : 'no_redirect'];
}

function do_unshorten(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $seen = []; $hops = 0; $cur = $url; $loop = 0;
    for ($i = 0; $i < 10; $i++) {
        if (!target_ok($cur)) break;
        $seen[$cur] = ($seen[$cur] ?? 0) + 1;
        if ($seen[$cur] > 1) { $loop = 1; break; }
        $ch = curl_init($cur);
        curl_setopt_array($ch, [CURLOPT_NOBODY => true, CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT_MS => 12000, CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => AGENT_UA]);
        agent_exec($ch);
        $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $loc  = (string)curl_getinfo($ch, CURLINFO_REDIRECT_URL);
        curl_close($ch);
        if ($code >= 300 && $code < 400 && $loc !== '') { $hops++; $cur = $loc; continue; }
        break;
    }
    return ['ok' => true, 'hops' => $hops, 'loop' => $loop,
            'final_host' => strtolower((string)(parse_url($cur, PHP_URL_HOST) ?: '')),
            'final_url'  => mb_substr($cur, 0, 300)];
}

/** cloaking.detect — is the page served to a search-engine bot materially different from the page
 *  a normal browser sees (#24)? Classic SEO cloaking / "clean to Google, malware to users". Fetches
 *  the SAME url with a Googlebot UA and a browser UA and compares two STABLE signals — the <title>
 *  and the normalized visible-text size ratio. The cloaked verdict is deterministic across devices
 *  (cloaking is UA-driven, so every vantage sees the same divergence), which is what quorum agrees on. */
function do_cloaking(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    $get = function (string $ua) use ($url) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15,
            CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 4, CURLOPT_USERAGENT => $ua]);
        $b = agent_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $eff = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); curl_close($ch);
        return [$b === false ? '' : (string)$b, $code, $eff];
    };
    [$bb, $bc, $be] = $get('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)');
    [$ub, $uc, $ue] = $get('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36');
    if ($bb === '' || $ub === '' || $bc === 0 || $uc === 0) return ['ok' => false, 'code' => $bc, 'err' => 'fetch_failed'];
    $title = function (string $h): string {
        return preg_match('~<title[^>]*>(.*?)</title>~is', $h, $m) ? trim(strtolower(preg_replace('~\s+~', ' ', strip_tags($m[1])))) : '';
    };
    $titleDiff = ($title($bb) !== $title($ub)) ? 1 : 0;
    $tb = strlen(normalize_text($bb)); $tu = strlen(normalize_text($ub));
    $ratio = $tu > 0 ? $tb / $tu : ($tb > 0 ? 9.9 : 1.0);
    $sizeDiff = ($ratio < 0.6 || $ratio > 1.7) ? 1 : 0;
    $destDiff = (strtolower((string)(parse_url($be, PHP_URL_HOST) ?: '')) !== strtolower((string)(parse_url($ue, PHP_URL_HOST) ?: ''))) ? 1 : 0;
    $cloaked = ($titleDiff || $sizeDiff || $destDiff) ? 1 : 0;
    return ['ok' => true, 'code' => $bc, 'cloaked' => $cloaked,
            'title_diff' => $titleDiff, 'size_diff' => $sizeDiff, 'dest_diff' => $destDiff];
}

/** content.authenticity — a deterministic HEURISTIC signal of how AI-generated a page reads (#123).
 *  NOT a verdict, and never proof: it counts well-known LLM tell-phrases and measures sentence-length
 *  uniformity (human writing is "bursty" — varied sentence lengths; generated text is flatter). Same
 *  text -> same score, so the network agrees on the bucket. Clearly a signal, not an accusation. */
function do_aicontent(array $payload): array {
    $url = (string)($payload['url'] ?? '');
    if (!target_ok($url)) return ['ok' => false, 'err' => 'target_refused'];
    if (!empty($payload['respect_robots']) && !robots_allows($url)) return ['ok' => false, 'err' => 'robots_disallow'];
    $ch = curl_init($url);
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS => min(30000, max(2000, (int)($payload['timeout_ms'] ?? 15000))),
        CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_USERAGENT => AGENT_UA, CURLOPT_COOKIEFILE => '']);
    $body = agent_exec($ch); $i = curl_getinfo($ch); $err = $body === false ? curl_error($ch) : null; curl_close($ch);
    if ($err !== null) return ['ok' => false, 'code' => 0, 'err' => substr($err, 0, 120)];
    $t = preg_replace('~<(script|style|noscript|template)\b[^>]*>.*?</\1>~is', ' ', (string)$body);
    $t = html_entity_decode(strip_tags((string)$t), ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $t = trim(preg_replace('~\s+~', ' ', (string)$t));
    if (strlen($t) > 512000) $t = substr($t, 0, 512000);   // audit: bound heuristic work on huge pages
    $words = str_word_count($t);
    if ($words < 120) return ['ok' => true, 'code' => (int)($i['http_code'] ?? 0), 'ai_score' => 0, 'phrase_hits' => 0, 'too_short' => 1];
    $tells = ['as an ai', 'as a language model', 'it is important to note', "it's important to note",
        'it is worth noting', 'in conclusion', 'in summary', 'delve into', 'a rich tapestry', 'tapestry of',
        'furthermore', 'moreover', 'in the realm of', 'navigating the', 'it is crucial to', 'plays a crucial role',
        'plays a vital role', 'a testament to', 'ever-evolving', 'in today\'s digital', 'unlock the', 'harness the power',
        'when it comes to', 'a myriad of', 'seamlessly', 'robust', 'leverage', 'game-changer'];
    $lt = ' ' . strtolower($t) . ' ';
    $hits = 0;
    foreach ($tells as $p) $hits += substr_count($lt, ' ' . $p . (str_ends_with($p, ' ') ? '' : ' '));
    // sentence-length burstiness: coefficient of variation of word-counts per sentence. Lower CV = flatter = more AI-like.
    $sent = preg_split('~(?<=[.!?])\s+~', $t) ?: [];
    $lens = [];
    foreach ($sent as $s) { $w = str_word_count($s); if ($w > 0) $lens[] = $w; }
    $cv = 1.0;
    if (count($lens) >= 5) {
        $mean = array_sum($lens) / count($lens);
        if ($mean > 0) { $var = 0; foreach ($lens as $l) $var += ($l - $mean) ** 2; $cv = sqrt($var / count($lens)) / $mean; }
    }
    // score: tell-phrase density (per 1000 words) + a flatness component. Bounded 0..100, bucketed to tens.
    $density = $hits / max(1, $words / 1000.0);
    $flat = max(0.0, 1.0 - min(1.0, $cv / 0.6));   // cv >= 0.6 (bursty/human) -> 0; cv ~0 (flat) -> 1
    $raw = min(100.0, $density * 12.0 + $flat * 55.0);
    $score = (int)(round($raw / 10.0) * 10);
    return ['ok' => true, 'code' => (int)($i['http_code'] ?? 0), 'ai_score' => $score, 'phrase_hits' => $hits, 'too_short' => 0];
}

/** port.scan.safe — device-side scan of a FIXED set of well-known service ports (#43), never a range
 *  scan, and only against a resolved PUBLIC IP (no private/loopback) so it cannot probe an internal
 *  network. Devices agree on the exact set of open ports. */
function do_portscan(array $payload): array {
    $host = (string)($payload['host'] ?? '');
    if ($host === '' || !preg_match('/^[a-z0-9.-]{1,253}$/i', $host)) return ['ok' => false, 'err' => 'bad_host'];
    $ip = null;
    foreach (@gethostbynamel($host) ?: [] as $a)
        if (filter_var($a, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { $ip = $a; break; }
    if ($ip === null) return ['ok' => false, 'err' => 'no_public_ip'];
    $ports = [21, 22, 25, 53, 80, 110, 143, 443, 465, 587, 993, 995, 3306, 5432, 6379, 8080, 8443];
    $open = [];
    foreach ($ports as $p) { $fp = @fsockopen($ip, $p, $e, $es, 2.0); if ($fp !== false) { $open[] = $p; @fclose($fp); } }
    sort($open);
    return ['ok' => true, 'open' => $open, 'open_n' => count($open)];
}


/* ─────────────────────── video.pack (идея №5): вторая ступень сжатия видеоотзыва ──────────────
 * Устройство скачивает исходник по ссылке с одноразовым токеном, готовит облегчённые варианты
 * (720p и 480p) и возвращает ФАКТЫ ИСХОДНИКА: длительность, разрешение, число кадров. Вердикт —
 * согласие двух устройств именно по этим числам: байты готовых файлов у двух кодировщиков не
 * совпадут никогда, а факты исходника читаются одинаково.
 *
 * Возможность `videopack` объявляется ТОЛЬКО после того, как машина доказала её делом
 * (videopack_ready): сборка ffmpeg без кодировщика прекрасно отвечает на -version и падает на
 * первом же задании, а вид, который устройство проваливает, стоит ему доверия.
 */

/** Есть ли на этой машине рабочий кодировщик. Проверяется делом, а не наличием файла. */
function videopack_ready(): bool {
    static $ok = null;
    if ($ok !== null) return $ok;
    if (!function_exists('exec')) return $ok = false;
    foreach (['ffmpeg', 'ffprobe'] as $bin) {
        $o = []; $rc = 1;
        @exec($bin . ' -version 2>/dev/null', $o, $rc);
        if ($rc !== 0) return $ok = false;
    }
    $tmp = sys_get_temp_dir() . '/pulsar-vp-' . bin2hex(random_bytes(6)) . '.mp4';
    $o = []; $rc = 1;
    @exec('ffmpeg -v error -y -f lavfi -i testsrc=size=320x240:rate=12:duration=1 -frames:v 12'
        . ' -c:v libx264 -preset ultrafast ' . escapeshellarg($tmp) . ' 2>/dev/null', $o, $rc);
    $good = $rc === 0 && is_file($tmp) && (int)@filesize($tmp) > 0;
    @unlink($tmp);
    return $ok = $good;
}

/** Факты файла: длительность (сек), ширина, высота, число кадров. null — не читается как видео. */
function videopack_probe(string $file): ?array {
    if (!is_file($file)) return null;
    $o = []; $rc = 1;
    @exec('ffprobe -v error -select_streams v:0 -count_packets'
        . ' -show_entries stream=width,height,nb_read_packets,duration'
        . ' -show_entries format=duration -of json ' . escapeshellarg($file) . ' 2>/dev/null', $o, $rc);
    if ($rc !== 0) return null;
    $j = json_decode(implode('', $o), true);
    if (!is_array($j) || empty($j['streams'][0])) return null;
    $s = $j['streams'][0];
    $dur = (float)($s['duration'] ?? ($j['format']['duration'] ?? 0));
    $w = (int)($s['width'] ?? 0); $h = (int)($s['height'] ?? 0); $f = (int)($s['nb_read_packets'] ?? 0);
    if ($w <= 0 || $h <= 0 || $f <= 0 || $dur <= 0) return null;
    return ['dur_s' => (int)round($dur), 'w' => $w, 'h' => $h, 'frames' => $f];
}

/** Размер варианта: короткая сторона = цель, длинная — пропорционально. null — исходник мельче. */
function videopack_size(int $w, int $h, int $target): ?array {
    $short = min($w, $h);
    if ($short <= $target) return null;
    $long = (int)round(max($w, $h) * ($target / $short));
    if ($long % 2 !== 0) $long++;
    return $w <= $h ? ['w' => $target, 'h' => $long] : ['w' => $long, 'h' => $target];
}

function do_videopack(array $payload): array {
    if (!videopack_ready()) return ['ok' => false, 'err' => 'no_encoder'];
    $src = (string)($payload['src'] ?? '');
    $up  = (string)($payload['up'] ?? '');
    if (!target_ok($src) || !target_ok($up)) return ['ok' => false, 'err' => 'target_refused'];
    $secret = (string)($GLOBALS['__pulsar_cfg']['secret'] ?? '');
    if ($secret === '') return ['ok' => false, 'err' => 'no_key'];
    $max = min(67108864, max(1024, (int)($payload['max_bytes'] ?? 67108864)));
    $want = [];
    foreach ((array)($payload['variants'] ?? ['720', '480']) as $v)
        if (in_array((string)$v, ['720', '480'], true)) $want[] = (string)$v;
    if (!$want) return ['ok' => false, 'err' => 'no_variants'];

    $dir = sys_get_temp_dir() . '/pulsar-vp-' . bin2hex(random_bytes(8));
    if (!@mkdir($dir, 0700, true)) return ['ok' => false, 'err' => 'tmp'];
    $clean = static function () use ($dir): void {
        foreach ((array)@glob($dir . '/*') as $f) @unlink($f);
        @rmdir($dir);
    };
    // 1. Скачать исходник. Ключ устройства обязателен: ссылка сама по себе ничего не открывает.
    $file = $dir . '/src.bin';
    $fh = @fopen($file, 'wb');
    if (!$fh) { $clean(); return ['ok' => false, 'err' => 'tmp']; }
    $ch = curl_init($src);
    curl_setopt_array($ch, [
        CURLOPT_FILE => $fh, CURLOPT_TIMEOUT => 300, CURLOPT_USERAGENT => AGENT_UA,
        CURLOPT_HTTPHEADER => ['X-Node-Key: ' . $secret], CURLOPT_FOLLOWLOCATION => false,
    ]);
    $okDl = agent_exec($ch, $max) !== false;
    $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch); fclose($fh);
    $bytes = (int)@filesize($file);
    if (!$okDl || $code !== 200 || $bytes <= 0) { $clean(); return ['ok' => false, 'code' => $code, 'err' => 'download']; }

    // 2. Факты исходника — их и подтверждает сеть.
    $p = videopack_probe($file);
    if ($p === null) { $clean(); return ['ok' => false, 'err' => 'unreadable']; }

    // 3. Готовим и отдаём варианты. Кадры и длительность не трогаются: меняется только размер.
    $out = ['ok' => true, 'code' => 200, 'bytes' => $bytes, 'src_bytes' => $bytes,
            'dur_s' => $p['dur_s'], 'w' => $p['w'], 'h' => $p['h'], 'frames' => $p['frames']];
    foreach ($want as $v) {
        $size = videopack_size($p['w'], $p['h'], (int)$v);
        if ($size === null) continue;                        // исходник уже мельче — вариант не нужен
        $dst = $dir . '/v' . $v . '.mp4';
        $o = []; $rc = 1;
        @exec('ffmpeg -v error -y -i ' . escapeshellarg($file)
            . ' -vf ' . escapeshellarg('scale=' . $size['w'] . ':' . $size['h'])
            // -threads 2 — не жадность, а работоспособность: по умолчанию x264 поднимает поток
            // на ядро, и под systemd с TasksMax (сервисная единица нашего же узла — 64) кодирование
            // 1080x1920 падало с пустым файлом, а вариант молча исчезал. Заодно это вежливее к
            // чужой машине: узел работает фоном, а не занимает все ядра.
            . ' -c:v libx264 -preset veryfast -crf 26 -threads 2 -pix_fmt yuv420p'
            . ' -c:a aac -b:a 96k -movflags +faststart ' . escapeshellarg($dst) . ' 2>/dev/null', $o, $rc);
        if ($rc !== 0 || !is_file($dst) || (int)@filesize($dst) <= 0) {
            say('video.pack: вариант ' . $v . 'p не закодировался (ffmpeg rc=' . $rc . ')');
            continue;                                    // молчащий пропуск — это потерянная работа
        }
        $vb = (int)@filesize($dst);
        $sha = (string)@hash_file('sha256', $dst);
        $u = $up . '&variant=' . $v;
        $in = @fopen($dst, 'rb');
        if (!$in) continue;
        $c2 = curl_init($u);
        curl_setopt_array($c2, [
            CURLOPT_UPLOAD => true, CURLOPT_CUSTOMREQUEST => 'POST',
            CURLOPT_INFILE => $in, CURLOPT_INFILESIZE => $vb,
            CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 300, CURLOPT_USERAGENT => AGENT_UA,
            CURLOPT_HTTPHEADER => ['X-Node-Key: ' . $secret, 'Content-Type: application/octet-stream',
                                   'Expect:'],
        ]);
        $resp = agent_exec($c2, $max);
        $rc2 = (int)curl_getinfo($c2, CURLINFO_HTTP_CODE);
        curl_close($c2); fclose($in);
        if ($resp === false || $rc2 !== 200) {
            say('video.pack: вариант ' . $v . 'p не принят сервером (код ' . $rc2 . ')');
            continue;                                    // не дошло — вариант не заявляем
        }
        $out['v' . $v . '_bytes'] = $vb;
        $out['v' . $v . '_sha']   = $sha;
    }
    $clean();
    return $out;
}

function run_task(array $t): array {
    $p = is_array($t['payload'] ?? null) ? $t['payload'] : [];
    $kind = (string)($t['kind'] ?? '');
    // #190 third-party plugin kinds: plugin.<name> -> pulsar_plugin_<name>(payload)
    if (str_starts_with($kind, 'plugin.')) {
        load_plugins();
        $fn = 'pulsar_plugin_' . preg_replace('/[^a-z0-9_]/', '', strtolower(substr($kind, 7)));
        return function_exists($fn) ? (array)$fn($p) : ['ok' => false, 'err' => 'unknown_plugin'];
    }
    return match ($kind) {
        'uptime.probe'  => do_probe($p, false),
        'perf.measure'  => do_probe($p, true),
        'ssl.audit'     => do_ssl($p),
        'content.watch' => do_content($p),
        'keyword.check' => do_keyword($p),
        'link.audit'    => do_link_audit($p),
        'dns.check'     => do_dns($p),
        'port.check'    => do_port($p),
        'api.check'     => do_apicheck($p),
        'infer.embed'   => do_infer($p),
        'infer.generate' => do_infer_generate($p),
        'serp.check'    => do_serp($p),
        'compress.audit' => do_compress($p),
        'ipv6.parity'    => do_ipv6($p),
        'mixed.scan'     => do_mixed($p),
        'redirect.chain' => do_redirect($p),
        'robots.diff'    => do_robots($p),
        'captcha.rate'   => do_captcha($p),
        'asset.integrity'=> do_integrity($p),
        'cdn.pop'        => do_cdn($p),
        'geo.block'      => do_geoblock($p),
        'journey.check'  => do_journey($p),
        'dns.dnssec'     => do_dnssec($p),
        'app.rank'       => do_apprank($p),
        'traceroute.map' => do_trace($p),
        'smtp.check'     => do_smtp($p),
        'rss.watch'     => do_rss($p),
        'meta.audit'    => do_meta($p),
        'email.audit'   => do_email($p),
        'favicon.clone' => do_favicon($p),
        'blacklist.check' => do_blacklist($p),
        'price.extract'  => do_price($p),
        'spelling.scan'  => do_spelling($p),
        'sitemap.diff'   => do_sitemapdiff($p),
        'link.unshorten' => do_unshorten($p),
        'link.location'  => do_redirloc($p),   // 30.08.2026: адрес из заголовка, без обращения к сайту
        'cloaking.detect' => do_cloaking($p),
        'content.authenticity' => do_aicontent($p),
        'port.scan.safe'  => do_portscan($p),
        'video.pack'      => do_videopack($p),   // идея №5: вторая ступень сжатия видеоотзыва
        default         => ['ok' => false, 'err' => 'unsupported_kind'],
    };
}

// ---------------------------------------------------------------- commands
$cmd = $argv[1] ?? 'help';

if ($cmd === 'pair') {
    $code = $argv[2] ?? '';
    if ($code === '' || str_starts_with($code, '--')) { fwrite(STDERR, "usage: pair <CODE> --accept-terms [--api=URL] [--label=NAME]\n"); exit(1); }
    $api = 'https://astrina.io'; $label = php_uname('n'); $accept = in_array('--accept-terms', $argv, true);
    $profile = 'full';
    foreach ($argv as $a) {
        if (str_starts_with($a, '--api='))     $api     = substr($a, 6);
        if (str_starts_with($a, '--label='))   $label   = substr($a, 8);
        if (str_starts_with($a, '--profile=')) $profile = substr($a, 10);
    }
    $profile = $profile ?? 'full';   // #187 lite | full | ai
    // Informed consent: this agent runs verified http probes / measurements of PUBLIC sites
    // the dispatcher assigns, uses a little CPU and network, sends no personal data, and can
    // be stopped any time. Enrolment requires accepting these terms with --accept-terms.
    if (!$accept) {
        fwrite(STDERR, "\nPulsar contributor terms:\n"
            . "  • Your device runs small, verified checks of PUBLIC websites assigned by the network.\n"
            . "  • It uses a little CPU and network. It sends NO personal data and reads NO private files.\n"
            . "  • Every task is logged; you can pause or remove the device at any time.\n"
            . "  Full terms: " . rtrim($api, '/') . "/pulsar/terms\n\n"
            . "Re-run with --accept-terms to agree and pair.\n");
        exit(1);
    }
    // #42 result signing: generate an Ed25519 keypair; the private key stays here, the public
    // key goes to the server so it can verify every result this device submits.
    $signPk = '';
    if (function_exists('sodium_crypto_sign_keypair')) {
        $kp = sodium_crypto_sign_keypair();
        $GLOBALS['__sign_sk'] = bin2hex(sodium_crypto_sign_secretkey($kp));
        $signPk = bin2hex(sodium_crypto_sign_publickey($kp));
    }
    $caps = profile_caps($profile);
    foreach (load_plugins() as $pn) $caps['plugin.' . $pn] = 1;   // #190 advertise local plugins
    $r = api(['api' => $api], 'register', [
        'pair_code' => $code, 'kind' => 'cli', 'label' => $label, 'consent' => 1, 'pubkey' => $signPk,
        'ver' => AGENT_VER, 'os' => PHP_OS_FAMILY,
        'caps' => $caps,   // #187 profile + #190 plugins
    ]);
    if (empty($r['ok'])) { say('pair failed: ' . ($r['error'] ?? 'unknown')); exit(1); }
    // sign_pk сохраняется ВМЕСТЕ с sign_sk: без него первый же цикл run видит пустой sign_pk,
    // чеканит второй ключ и начинает подписывать им — а сервер знает ключ пары и отвергает
    // каждый ответ (найдено живым прогоном 23.08.2026).
    cfg_save(['api' => $api, 'device_id' => $r['device_id'], 'secret' => $r['secret'], 'label' => $label, 'profile' => $profile, 'sign_sk' => $GLOBALS['__sign_sk'] ?? '', 'sign_pk' => $signPk]);
    say('capability profile: ' . $profile);
    say('paired as device #' . $r['device_id'] . ' — config saved to ' . cfg_path());
    exit(0);
}

/* ---------------------------------------------------------------------------------------------
 * F24 Fleet configuration: export/import.
 *
 * The point of an export is to install the SAME SETTINGS on many machines. The point it must NOT
 * serve is cloning an IDENTITY: device_id, secret and sign_sk name exactly one device to the
 * network. Copy those onto fifty machines and fifty machines report as one device — the network
 * would see wild, contradictory results from a single id and distrust it, and the operator would
 * lose the earnings of forty-nine devices while wondering why.
 *
 * So the default export is a settings TEMPLATE with identity removed, and it says so out loud.
 * --with-identity exists for the one legitimate case (moving one device to new hardware) and
 * announces that it carries a secret.
 * --------------------------------------------------------------------------------------------- */
const AGENT_IDENTITY_KEYS = ['device_id', 'secret', 'sign_sk', 'sign_pk'];
const AGENT_FLEET_KEYS    = ['api', 'profile', 'active_hours', 'max_mb_day', 'exclude'];

function cfg_export(array $c, bool $withIdentity): array {
    $out = [];
    foreach (AGENT_FLEET_KEYS as $k) if (isset($c[$k])) $out[$k] = $c[$k];
    // 'label' is deliberately absent from a fleet template: fifty devices all called "web-01" are
    // indistinguishable in the dashboard. Each install picks up its own hostname at pair time.
    if ($withIdentity) foreach (AGENT_IDENTITY_KEYS as $k) if (isset($c[$k])) $out[$k] = $c[$k];
    return $out;
}

/** Merge an exported file into the local config. Identity in the file is applied ONLY when the
 *  operator asked for it explicitly; otherwise this device keeps its own. */
function cfg_import(array $cur, array $in, bool $withIdentity): array {
    foreach (AGENT_FLEET_KEYS as $k) if (array_key_exists($k, $in)) $cur[$k] = $in[$k];
    if ($withIdentity) foreach (AGENT_IDENTITY_KEYS as $k) if (array_key_exists($k, $in)) $cur[$k] = $in[$k];
    return $cur;
}

/* ---------------------------------------------------------------------------------------------
 * F25 Integrity check against the published build.
 *
 * Honest about what this is: the installer already verifies the download, and this re-checks the
 * file ON DISK afterwards, so it catches a build that was corrupted, patched by another tool, or
 * quietly edited on a shared machine. It is NOT protection against an attacker who already has
 * write access to this file — such an attacker deletes this function first. It is a smoke alarm,
 * not a lock, and the text says exactly that.
 *
 * A mismatch has two very different causes, and calling the wrong one "tampering" would be a false
 * alarm on every routine release day, so they are separated: a newer version published upstream is
 * simply an update, and only an equal version with a different hash is a modified file.
 * --------------------------------------------------------------------------------------------- */
function integrity_check(array $c): array {
    $base = rtrim((string)($c['api'] ?? 'https://astrina.io'), '/');
    $ch = curl_init($base . '/pulsar/setup?sums=1');
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20,
        CURLOPT_FOLLOWLOCATION => true, CURLOPT_USERAGENT => AGENT_UA]);
    $body = agent_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
    if ($body === false || $code !== 200) return ['state' => 'unknown', 'why' => 'cannot reach the checksum list (HTTP ' . $code . ')'];

    $want = '';
    foreach (preg_split('/\r?\n/', (string)$body) as $ln) {
        if (preg_match('/^([0-9a-f]{64})\s+\*?(\S+)$/i', trim($ln), $m) && basename($m[2]) === 'pulsar-agent.php') { $want = strtolower($m[1]); break; }
    }
    if ($want === '') return ['state' => 'unknown', 'why' => 'the published list does not name pulsar-agent.php'];

    $have = (string)hash_file('sha256', __FILE__);
    if (hash_equals($want, $have)) return ['state' => 'ok', 'sha' => $have];

    // Different — find out which kind of different.
    $ch = curl_init($base . '/pulsar/setup?dl=agent');
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,
        CURLOPT_FOLLOWLOCATION => true, CURLOPT_USERAGENT => AGENT_UA]);
    $pub = agent_exec($ch); curl_close($ch);
    $pubVer = (is_string($pub) && preg_match("/AGENT_VER\s*=\s*'([0-9.]+)'/", $pub, $m)) ? $m[1] : '';
    if ($pubVer !== '' && version_compare($pubVer, AGENT_VER, '>'))
        return ['state' => 'outdated', 'have' => AGENT_VER, 'published' => $pubVer, 'sha' => $have];
    return ['state' => 'modified', 'want' => $want, 'sha' => $have, 'published' => $pubVer ?: AGENT_VER];
}

if ($cmd === 'export-config') {
    $c = cfg_load();
    if (!$c) { fwrite(STDERR, "nothing to export — no config at " . cfg_path() . "\n"); exit(1); }
    $withId = in_array('--with-identity', $argv, true);
    $out = cfg_export($c, $withId);
    fwrite(STDERR, $withId
        ? "!! This file contains this device's SECRET. Treat it as a credential: it moves ONE device\n"
          . "!! to new hardware. Do NOT install it on several machines — they would all report as\n"
          . "!! device #" . (string)($c['device_id'] ?? '?') . " and the network would distrust the lot.\n"
        : "# Fleet template: settings only. Identity (device_id, secret, signing key) and label are\n"
          . "# left out on purpose, so every machine you install this on pairs as its own device.\n"
          . "# Install with:  php pulsar-agent.php import-config <file>   then:  pair <CODE>\n");
    echo json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
    exit(0);
}

if ($cmd === 'import-config') {
    $src = $argv[2] ?? '';
    if ($src === '' || str_starts_with($src, '--')) { fwrite(STDERR, "usage: import-config <file|-> [--with-identity] [--config=PATH]\n"); exit(1); }
    $raw = $src === '-' ? (string)stream_get_contents(STDIN) : (string)@file_get_contents($src);
    if ($raw === '') { fwrite(STDERR, "cannot read " . $src . "\n"); exit(1); }
    $in = json_decode($raw, true);
    if (!is_array($in)) { fwrite(STDERR, "not valid JSON\n"); exit(1); }
    $withId = in_array('--with-identity', $argv, true);
    $cur = cfg_load();
    $hasId = (bool)array_intersect(AGENT_IDENTITY_KEYS, array_keys($in));
    if ($hasId && !$withId)
        say('note: the file carries an identity; ignoring it. This device keeps its own. Pass --with-identity to take it over.');
    $new = cfg_import($cur, $in, $withId);
    cfg_save($new);
    $applied = array_values(array_intersect(AGENT_FLEET_KEYS, array_keys($in)));
    say('imported into ' . cfg_path() . ': ' . ($applied ? implode(', ', $applied) : 'nothing to apply')
        . ($withId && $hasId ? ' + identity' : ''));
    if (empty($new['device_id'])) say('not paired yet — run: pair <CODE> --accept-terms');
    exit(0);
}

if ($cmd === 'verify') {
    $r = integrity_check(cfg_load());
    if ($r['state'] === 'ok')       { say('intact: this file matches the published build ' . AGENT_VER . ' (sha256 ' . substr($r['sha'], 0, 16) . '…)'); exit(0); }
    if ($r['state'] === 'unknown')  { say('could not verify: ' . $r['why']); exit(2); }
    if ($r['state'] === 'outdated') { say('this is agent ' . $r['have'] . ', the published build is ' . $r['published'] . ' — run: update'); exit(3); }
    say('MODIFIED: this file is version ' . AGENT_VER . ', same as published, but its contents differ.');
    say('  published sha256: ' . $r['want']);
    say('  this file:        ' . $r['sha']);
    say('  Re-install from https://astrina.io/pulsar/setup if you did not edit it yourself.');
    say('  (This check is a smoke alarm, not a lock: anyone able to edit this file could remove it.)');
    exit(4);
}

if ($cmd === 'block-config') {
    // Enable this device as a block HOLDER in one step: agent block-config <host:port> [runtime-bin].
    // Writes block_serve + rpc_endpoint (+ optional binary path); the next heartbeat then advertises
    // caps.rpc_endpoint so the coordinator can fold this device into a pipeline chain.
    $ep  = trim((string)($argv[2] ?? ''));
    if (!preg_match('~^[a-zA-Z0-9.\-]{1,120}:\d{2,5}$~', $ep)) {
        fwrite(STDERR, "usage: block-config <host:port> [path-to-ggml-rpc-server]\n"); exit(1);
    }
    $c = cfg_load();
    $c['block_serve']  = 1;
    $c['rpc_endpoint'] = $ep;
    // positional [runtime-bin] is any non-flag 3rd arg; --mtls turns on transport hardening.
    foreach (array_slice($argv, 3) as $a) {
        if ($a === '--mtls') $c['block_mtls'] = 1;
        elseif (strncmp($a, '--', 2) !== 0) $c['block_runtime_bin'] = (string)$a;
    }
    cfg_save($c);
    $GLOBALS['__pulsar_cfg'] = $c;
    $bin = block_runtime_bin();
    say('block holder configured: endpoint ' . $ep
        . ($bin !== '' ? ' · runtime ' . $bin : ' · WARNING: no ggml-rpc-server found yet (set path or install it)'));
    say('start serving with: agent block-serve');
    exit(0);
}
if ($cmd === 'block-serve') {
    // S2: serve this device's layer block. Without mTLS the raw rpc-server binds the endpoint
    // directly (use only on a trusted/loopback network). With mTLS (block-config --mtls) the
    // rpc-server binds LOOPBACK and a socat mutual-TLS terminator faces the network on the
    // public port — only a coordinator with a Pulsar-CA-signed cert connects (§5).
    $bin = block_runtime_bin();
    if ($bin === '') { fwrite(STDERR, "no block runtime binary (ggml-rpc-server) found; set block_runtime_bin in config\n"); exit(1); }
    $c = cfg_load();
    $ep = trim((string)($c['rpc_endpoint'] ?? '127.0.0.1:50052'));
    [$host, $port] = array_pad(explode(':', $ep, 2), 2, '50052');
    if (!preg_match('~^[a-zA-Z0-9.\-]+$~', $host) || !ctype_digit((string)$port)) { fwrite(STDERR, "bad rpc_endpoint\n"); exit(1); }
    if (empty($c['block_mtls'])) {
        say('serving block runtime on ' . $host . ':' . $port . ' (' . $bin . ') — WARNING: no mTLS; safe only on a trusted/loopback network. Enable with: block-config <ep> --mtls');
        passthru(escapeshellarg($bin) . ' --host ' . escapeshellarg($host) . ' --port ' . escapeshellarg((string)$port));
        exit(0);
    }
    // --- mTLS path ---
    $socat = block_socat_bin();
    if ($socat === '') { fwrite(STDERR, "block_mtls needs socat (bundled or system) on this device\n"); exit(1); }
    $dir = dirname(cfg_path());
    $certFile = $dir . '/block-cert.pem'; $caFile = $dir . '/block-ca.pem';
    if (!is_file($certFile) || !is_file($caFile) || (int)($c['block_cert_at'] ?? 0) < time() - 20 * 86400) {
        $r = api($c, 'cert', []);
        if (empty($r['ok']) || empty($r['cert'])) { fwrite(STDERR, 'cert fetch failed: ' . json_encode($r) . "\n"); exit(1); }
        file_put_contents($certFile, (string)$r['key'] . "\n" . (string)$r['cert']); @chmod($certFile, 0600);
        file_put_contents($caFile, (string)$r['ca']); @chmod($caFile, 0640);
        $c['block_cert_at'] = time(); cfg_save($c);
        say('fetched mTLS certificate (' . ($r['cn'] ?? '') . ')');
    }
    $localrpc = (int)($c['block_rpc_local'] ?? 50052);
    $rp = []; @exec('setsid ' . escapeshellarg($bin) . ' --host 127.0.0.1 --port ' . $localrpc . ' >/dev/null 2>&1 & echo $!', $rp);
    $rpid = (int)($rp[0] ?? 0);
    say('block runtime on loopback :' . $localrpc . ' · mTLS terminator on ' . $host . ':' . $port);
    passthru(escapeshellarg($socat) . ' OPENSSL-LISTEN:' . (int)$port . ',cert=' . escapeshellarg($certFile) . ',cafile=' . escapeshellarg($caFile) . ',verify=1,reuseaddr,fork TCP:127.0.0.1:' . $localrpc);
    if ($rpid > 0 && function_exists('posix_kill')) @posix_kill($rpid, 15);
    exit(0);
}
if ($cmd === 'run') {
    $c = cfg_load();
    if (empty($c['device_id'])) { fwrite(STDERR, "not paired yet — run: pair <CODE>\n"); exit(1); }
    $once = in_array('--once', $argv, true);
    $GLOBALS['__pulsar_exclude'] = is_array($c['exclude'] ?? null) ? $c['exclude'] : [];
    // #100 daily egress budget (MB): stop pulling work once the day's byte budget is spent.
    $maxMb = (int)($c['max_mb_day'] ?? 0);
    $budgetFile = cfg_path() . '.usage';
    say('Pulsar agent ' . AGENT_VER . ' up as device #' . $c['device_id']
        . ($maxMb > 0 ? ' (daily cap ' . $maxMb . ' MB)' : '')
        . ($GLOBALS['__pulsar_exclude'] ? ' · ' . count($GLOBALS['__pulsar_exclude']) . ' excluded host(s)' : ''));
    /* F25 Say something at startup, then once a day. Never blocks the loop and never exits: an agent
     * that stopped working because a checksum server was unreachable would be a worse outage than the
     * problem it is watching for. */
    $nextVerify = 0;
    $verifyTick = function (array $c) use (&$nextVerify) {
        if (time() < $nextVerify) return;
        $nextVerify = time() + 86400;
        $r = integrity_check($c);
        if ($r['state'] === 'modified')
            say('WARNING: this agent file differs from the published build ' . AGENT_VER
                . ' — re-install from ' . rtrim((string)($c['api'] ?? 'https://astrina.io'), '/') . '/pulsar/setup if you did not edit it.');
        elseif ($r['state'] === 'outdated')
            say('a newer agent is published (' . $r['published'] . ', this is ' . $r['have'] . ') — run: update');
    };
    $verifyTick($c);
    while (true) {
        // #180 work-hours window: rest outside the operator's configured local hours.
        if (!within_hours($c)) { if ($once) exit(0); sleep(600); continue; }
        // #100 enforce the daily egress budget before asking for more work.
        if ($maxMb > 0) {
            $u = @json_decode((string)@file_get_contents($budgetFile), true) ?: [];
            if (($u['day'] ?? '') === gmdate('Y-m-d') && (int)($u['bytes'] ?? 0) >= $maxMb * 1048576) {
                say('daily ' . $maxMb . ' MB budget reached — resting until UTC midnight');
                if ($once) exit(0); sleep(900); continue;
            }
        }
        // Re-declare capabilities on every heartbeat: an agent that gains abilities on upgrade
        // (or hardware that gains memory) otherwise keeps advertising what it could do on the day
        // it was paired, and the operator has no way to fix that short of re-pairing.
        $hb = api($c, 'heartbeat', ['ver' => AGENT_VER, 'os' => PHP_OS_FAMILY,
                                    'caps' => profile_caps((string)($c['profile'] ?? 'full')),
                                    'health' => agent_health($budgetFile)]);   // #182/#189
        if (!empty($hb['min_version']) && version_compare(AGENT_VER, (string)$hb['min_version'], '<')) {
            say('agent too old (need ' . $hb['min_version'] . ') — please update'); exit(2);
        }
        if (!empty($hb['pause'])) { say('paused by server'); if ($once) exit(0); sleep(60); continue; }
        // F57 devices enrolled before result-signing existed have no key, and the key was only
        // ever offered at registration — so they could never start signing. Mint one and register
        // it through the probe handshake: the server hands us a sample, we sign it, and the key is
        // adopted ONLY if our canonical form matches theirs. Registering on our own word would risk
        // the opposite of what signing is for — every result rejected, and earnings stopping
        // silently, because of one character of difference in how we render JSON.
        if (empty($c['sign_pk']) && function_exists('sodium_crypto_sign_keypair')) agent_register_key($c);
        $n = api($c, 'next', ['want' => 5, 'clock' => time()]);   // #239 clock-drift monitoring
        $tasks = is_array($n['tasks'] ?? null) ? $n['tasks'] : [];
        $pollHint = (int)($n['next_poll'] ?? 0);   // server tells us when to come back
        /* F9/F11 The owner's rules, as the account states them. Only `hours` is enforceable here:
         * a CLI node on a server has no battery and no notion of a metered network, so honouring
         * charging_only/wifi_only would be a claim this build cannot keep. They travel to the mobile
         * clients, which can. Saying so out loud beats pretending the flag did something. */
        // Идея №5: do_videopack ходит на наши же эндпоинты ключом устройства.
        $GLOBALS['__pulsar_cfg'] = $c;
        $GLOBALS['__pulsar_srv_hours'] = is_array($n['power_pref'] ?? null)
            ? trim((string)($n['power_pref']['hours'] ?? '')) : '';
        if ($tasks) {
            $results = [];
            foreach ($tasks as $t) {
                $r0 = microtime(true);
                // #188 reuse a very-recent identical check instead of refetching.
                $res = task_cache_get($budgetFile . '.cache', $t) ?? run_task($t);
                task_cache_put($budgetFile . '.cache', $t, $res);
                $one = ['assign_id' => (int)$t['assign_id'], 'result' => $res,
                        'ms' => (int)round((microtime(true) - $r0) * 1000)];
                // #42 sign the canonical (assign_id + sorted-key result) so the server can verify.
                // F57 when the task carries a server nonce, sign assign_id:nonce:result instead.
                // Assign ids are sequential and therefore guessable; the nonce is not, so the
                // signature can only have been made after this assignment was handed out. An old
                // server that sends no nonce still gets the message it knows how to check.
                if (!empty($c['sign_sk']) && function_exists('sodium_crypto_sign_detached')) {
                    $rr = $res; ksort($rr);
                    $nonce = (string)($t['nonce'] ?? '');
                    $msg = $one['assign_id'] . ':'
                         . ($nonce !== '' ? $nonce . ':' : '')
                         . json_encode($rr, JSON_UNESCAPED_SLASHES);
                    $one['sig'] = bin2hex(sodium_crypto_sign_detached($msg, hex2bin((string)$c['sign_sk'])));
                }
                $results[] = $one;
                if ($maxMb > 0) {
                    $u = @json_decode((string)@file_get_contents($budgetFile), true) ?: [];
                    if (($u['day'] ?? '') !== gmdate('Y-m-d')) $u = ['day' => gmdate('Y-m-d'), 'bytes' => 0];
                    $u['bytes'] = (int)($u['bytes'] ?? 0) + (int)($res['bytes'] ?? 0) + 2048; // + rough header/overhead
                    @file_put_contents($budgetFile, json_encode($u));
                }
                say(($t['kind'] ?? '?') . ' #' . $t['assign_id'] . ' → ' . ($res['ok'] ? ('code ' . ($res['code'] ?? '?')) : ('ERR ' . ($res['err'] ?? '?'))));
            }
            // F19 send anything stranded by an earlier outage together with this batch.
            $queued = queue_load($c);
            $payload = $results;
            foreach ($queued as $e) if (isset($e['r'])) $payload[] = $e['r'];
            $s = api($c, 'submit', ['results' => $payload]);
            if (empty($s['ok'])) {
                queue_add($c, $results);          // keep the work; the server never saw it
                say('submit failed — ' . count(queue_load($c)) . ' result(s) queued for retry');
            } else {
                if ($queued) say('flushed ' . count($queued) . ' queued result(s)');
                queue_clear($c);
                say('submitted ' . count($payload) . ', accepted ' . count($s['accepted'] ?? []));
            }
        } else {
            // F22 the server now says WHY there is nothing, instead of leaving a healthy node
            // looking broken to its owner.
            say('no tasks' . (!empty($n['idle_text']) ? ' — ' . $n['idle_text'] : ''));
        }
        if ($once) exit(0);
        // Honor the server's poll-interval hint (backs off when the pool is empty),
        // clamped to a sane range; fall back to local adaptive timing if none given.
        // F20 exponential backoff WITH jitter when the API is unreachable: a fixed retry from a
        // fleet of nodes turns any brief outage into a synchronised stampede the moment it ends.
        if (empty($hb['ok']) && empty($n['ok'])) {
            $fails = min(6, ($fails ?? 0) + 1);
            $wait  = min(600, (int)(15 * (2 ** ($fails - 1))));
            $wait  = max(5, $wait + random_int(-(int)($wait * 0.2), (int)($wait * 0.2)));   // jitter
            say('api unreachable — retrying in ' . $wait . 's');
        } else {
            $fails = 0;
            $wait  = $pollHint > 0 ? max(3, min(120, $pollHint)) : ($tasks ? 5 : 30);
        }
        sleep($wait);
    }
}

if ($cmd === 'check') {
    // #192 headless one-shot for CI: probe a URL locally (uptime + perf + ssl) and print JSON.
    // No pairing needed. Exit 0 only if the site answered 2xx/3xx; non-zero otherwise (fail a build).
    $url = $argv[2] ?? '';
    if ($url === '' || str_starts_with($url, '--') || !preg_match('~^https?://~i', $url)) {
        fwrite(STDERR, "usage: check <https://url>\n"); exit(2);
    }
    $p = ['url' => $url, 'timeout_ms' => 15000];
    $probe = run_task(['kind' => 'perf.measure', 'payload' => $p]);
    $ssl = str_starts_with(strtolower($url), 'https://') ? run_task(['kind' => 'ssl.audit', 'payload' => $p]) : ['ok' => null];
    $out = ['ok' => !empty($probe['ok']), 'url' => $url, 'code' => (int)($probe['code'] ?? 0),
            'ttfb_ms' => $probe['ttfb_ms'] ?? null, 'total_ms' => $probe['total_ms'] ?? null,
            'tls_days' => $ssl['tls_days'] ?? null, 'hsts' => isset($ssl['hsts']) ? (bool)$ssl['hsts'] : null];
    fwrite(STDOUT, json_encode($out, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n");
    $code = (int)($probe['code'] ?? 0);
    exit(!empty($probe['ok']) && $code >= 200 && $code < 400 ? 0 : 1);
}

if ($cmd === 'egress-audit') {
    // #100 Prove this node is not leaking anywhere unexpected: report the ONE control-plane host it
    // talks to, the exact public IP it presents (as the dispatcher sees it — not a third party), and
    // the daily egress budget + today's usage. By design the agent contacts ONLY the dispatcher and
    // the PUBLIC targets the dispatcher assigns; it opens no other persistent connection and reads no
    // local files beyond its own config.
    $c = cfg_load();
    $api = (string)($c['api'] ?? 'https://astrina.io');
    $host = (string)(parse_url($api, PHP_URL_HOST) ?: $api);
    $seen = '(pair first to confirm via the dispatcher)';
    if (!empty($c['device_id'])) {
        $hb = api($c, 'heartbeat', ['ver' => AGENT_VER, 'os' => PHP_OS_FAMILY]);
        if (!empty($hb['seen_ip'])) $seen = (string)$hb['seen_ip'];
    }
    $maxMb = (int)($c['max_mb_day'] ?? 0);
    $used = 0; $ub = @json_decode((string)@file_get_contents(cfg_path() . '.usage'), true) ?: [];
    if (($ub['day'] ?? '') === gmdate('Y-m-d')) $used = (int)($ub['bytes'] ?? 0);
    fwrite(STDOUT,
        "Pulsar egress self-audit (agent " . AGENT_VER . ")\n" .
        "  control plane:   https://$host  (the ONLY host this agent reports to)\n" .
        "  public IP shown: $seen\n" .
        "  daily budget:    " . ($maxMb > 0 ? $maxMb . ' MB' : 'unlimited') . " · used today: " . round($used / 1048576, 2) . " MB\n" .
        "  scope:           dispatcher + dispatcher-assigned PUBLIC http(s) targets only; no other\n" .
        "                   persistent connection; no local files beyond " . cfg_path() . "\n" .
        "  targets are validated public (no private/loopback/link-local), so a task cannot make this\n" .
        "                   device reach an internal service on its network.\n");
    exit(0);
}

if ($cmd === 'diag') {
    // #181 self-diagnosis: egress reachability, clock drift vs the network, and capability report.
    $c = cfg_load();
    $paired = !empty($c['device_id']);
    // egress-ok + clock drift: one request to the network; read the server Date header.
    $hdr = ''; $t0 = microtime(true);
    $ch = curl_init('https://astrina.io/pulsar');
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_NOBODY => true, CURLOPT_TIMEOUT => 12,
        CURLOPT_USERAGENT => AGENT_UA,
        CURLOPT_HEADERFUNCTION => function ($x, $l) use (&$hdr) { $hdr .= $l; return strlen($l); }]);
    $ok = agent_exec($ch) !== false; $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
    $rtt = (int)round((microtime(true) - $t0) * 1000);
    $egress = ($ok && $code > 0) ? "ok (HTTP $code, {$rtt}ms)" : 'FAILED — no network egress';
    $drift = 'unknown';
    if (preg_match('/^date:\s*(.+)$/im', $hdr, $dm)) {
        $srv = strtotime(trim($dm[1]));
        if ($srv) { $d = time() - $srv; $drift = ($d >= 0 ? '+' : '') . $d . 's' . (abs($d) > 60 ? '  ⚠ CLOCK DRIFT' : ''); }
    }
    $h = agent_health(cfg_path() . '.usage');
    $signing = function_exists('sodium_crypto_sign_detached') ? 'yes' : 'NO (results unsigned)';
    fwrite(STDOUT,
        "Pulsar self-diagnosis (agent " . AGENT_VER . ")\n" .
        "  paired:      " . ($paired ? 'device #' . $c['device_id'] : 'NO — run: pair <CODE>') . "\n" .
        "  egress:      $egress\n" .
        "  clock drift: $drift\n" .
        "  signing:     $signing\n" .
        "  php:         " . PHP_VERSION . " on " . PHP_OS_FAMILY . "\n" .
        "  curl/dns:    " . (function_exists('curl_init') ? 'curl ' : '') . (function_exists('dns_get_record') ? 'dns' : '') . "\n" .
        "  load1:       " . $h['load1'] . "   mem: " . $h['mem_pct'] . "%   uptime: " . $h['uptime_s'] . "s   egress today: " . round($h['bytes_today'] / 1048576, 2) . " MB\n");
    // Healthy exit only when egress works, signing is available, and the clock is within a minute.
    $healthy = ($ok && $code > 0) && (strpos($signing, 'yes') === 0) && (strpos((string)$drift, 'DRIFT') === false);
    exit($healthy ? 0 : 1);
}

if ($cmd === 'rotate-key') {
    // #93 rotate the signing key with continuity: sign the NEW public key with the CURRENT secret,
    // the server verifies against the old public key, then we swap our stored secret key.
    $c = cfg_load();
    if (empty($c['device_id']) || empty($c['sign_sk'])) { fwrite(STDERR, "not paired, or this device has no signing key\n"); exit(1); }
    if (!function_exists('sodium_crypto_sign_keypair')) { say('sodium unavailable — cannot rotate'); exit(1); }
    $kp = sodium_crypto_sign_keypair();
    $newSk = bin2hex(sodium_crypto_sign_secretkey($kp));
    $newPub = bin2hex(sodium_crypto_sign_publickey($kp));
    $sig = bin2hex(sodium_crypto_sign_detached(hex2bin($newPub), hex2bin((string)$c['sign_sk'])));
    $r = api($c, 'rotate-key', ['new_pubkey' => $newPub, 'sig' => $sig]);
    if (empty($r['ok'])) { say('key rotation failed: ' . ($r['error'] ?? 'unknown')); exit(1); }
    $c['sign_sk'] = $newSk; cfg_save($c);
    say('signing key rotated — new public key registered, old key retired.');
    exit(0);
}

if ($cmd === 'health') {
    // Machine-readable local health for docker HEALTHCHECK / external monitors. Exit 0 = paired.
    $c = cfg_load();
    $paired = !empty($c['device_id']);
    echo json_encode(['ok' => $paired, 'version' => AGENT_VER,
                      'device_id' => $paired ? (int)$c['device_id'] : null], JSON_UNESCAPED_SLASHES) . "\n";
    exit($paired ? 0 : 1);
}

if ($cmd === 'status') {
    $c = cfg_load();
    if (empty($c['device_id'])) { fwrite(STDERR, "not paired yet — run: pair <CODE>\n"); exit(1); }
    $s = api($c, 'stats', []);
    if (empty($s['ok'])) { say('could not reach the network'); exit(1); }
    printf("Pulsar device #%s (%s)\n  trust:      %.1f%%\n  tasks done: %s\n  AP today:   %s\n  AP total:   %s\n",
        (string)$c['device_id'], (string)($c['label'] ?? ''), (float)($s['trust'] ?? 0) * 100,
        (string)($s['tasks_done'] ?? 0), (string)($s['ap_today'] ?? 0), (string)($s['ap_total'] ?? 0));
    exit(0);
}

if ($cmd === 'update' || $cmd === 'self-update') {
    // Fetch the current published agent and replace this file if the version differs.
    // The distributable is public and carries no secrets; the config (secret) is separate.
    // audit: self-update ALWAYS pulls the published agent from the canonical HTTPS origin. --api is
    // ignored here so a rogue argument/config cannot replace this file with code from an
    // attacker-controlled host (the distributable is unsigned, so the origin is the trust anchor).
    $url = 'https://astrina.io/pulsar/setup?dl=agent';
    $ch = curl_init($url);
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,
        CURLOPT_FOLLOWLOCATION => true, CURLOPT_USERAGENT => AGENT_UA]);
    $body = agent_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
    if ($body === false || $code !== 200 || !str_contains((string)$body, 'AGENT_VER')) {
        say('update download failed (HTTP ' . $code . ')'); exit(1);
    }
    if (!preg_match("/AGENT_VER\\s*=\\s*'([0-9.]+)'/", (string)$body, $m)) { say('cannot read new version'); exit(1); }
    if (version_compare($m[1], AGENT_VER, '<=')) { say('already up to date (' . AGENT_VER . ')'); exit(0); }
    $self = __FILE__;
    if (!is_writable($self)) { say('cannot write ' . $self . ' — check permissions'); exit(1); }
    copy($self, $self . '.bak');
    file_put_contents($self, $body);
    say('updated ' . AGENT_VER . ' -> ' . $m[1] . ' (backup: ' . basename($self) . '.bak). Restart the agent.');
    exit(0);
}

if ($cmd === 'install') {
    // Emit a ready systemd unit for this exact install (no root needed to print it).
    $c = cfg_load();
    $self = __FILE__; $php = PHP_BINARY ?: '/usr/bin/php';
    $user = get_current_user() ?: 'pulsar';
    $cfgArg = '';
    foreach ($argv as $a) if (str_starts_with($a, '--config=')) $cfgArg = ' ' . $a;
    echo "# Save as /etc/systemd/system/pulsar-node.service, then:\n";
    echo "#   sudo systemctl daemon-reload && sudo systemctl enable --now pulsar-node\n\n";
    echo "[Unit]\nDescription=Pulsar node agent (astrina.io)\nAfter=network-online.target\nWants=network-online.target\n\n";
    $cfgDir = dirname(cfg_path());
    echo "[Service]\nType=simple\nUser=$user\nWorkingDirectory=" . dirname($self) . "\n";
    echo "ExecStart=$php $self run$cfgArg\nRestart=always\nRestartSec=30\nNice=10\nIOSchedulingClass=idle\n";
    // #99 kernel sandbox (seccomp + namespace isolation): the agent only fetches public URLs and
    // reads/writes its own config, so almost everything else can be denied. SystemCallFilter applies a
    // seccomp allowlist; the Protect*/Restrict* lines wall it off from the rest of the host. Its ONLY
    // writable paths are its working dir + config dir.
    echo "# --- #99 hardening (safe to keep; remove a line only if your PHP build needs it) ---\n";
    echo "NoNewPrivileges=yes\nProtectSystem=strict\nProtectHome=read-only\nPrivateTmp=yes\nPrivateDevices=yes\n";
    echo "ProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectClock=yes\nProtectHostname=yes\n";
    echo "RestrictNamespaces=yes\nRestrictRealtime=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\nRemoveIPC=yes\n";
    echo "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX\nSystemCallArchitectures=native\nSystemCallFilter=@system-service\nSystemCallFilter=~@resources @mount @swap @reboot @obsolete @cpu-emulation\nSystemCallErrorNumber=EPERM\n";
    echo "CapabilityBoundingSet=\nAmbientCapabilities=\nReadWritePaths=" . dirname($self) . " $cfgDir\n\n";
    echo "[Install]\nWantedBy=multi-user.target\n";
    echo "\n# AppArmor: a matching profile template ships alongside the agent as pulsar-node.apparmor.\n";
    echo "#   sudo cp " . dirname($self) . "/pulsar-node.apparmor /etc/apparmor.d/pulsar-node && sudo apparmor_parser -r /etc/apparmor.d/pulsar-node\n";
    exit(0);
}

fwrite(STDERR, "Pulsar reference agent " . AGENT_VER . "\n  pair <CODE> --accept-terms [--api=URL] [--label=NAME] [--profile=lite|full|ai]\n  run [--once] [--config=PATH]\n  status              earnings & trust for this device\n  update              fetch and install the latest agent\n  install [--config=] print a systemd unit for this install\n  health              JSON status; exit 0 when paired (for docker/monitoring)\n  verify              check this file against the published build (sha256)\n  export-config [--with-identity]   print settings for installing on other machines\n  import-config <file|->            apply settings exported from another machine\n  diag                self-check: egress, clock drift, signing, capability, resource use\n  egress-audit        prove where this node connects + the public IP it presents\n  rotate-key          generate a new signing key, proven by the old one\n  check <url>         headless one-shot probe (uptime+perf+ssl) as JSON; for CI\n");
exit($cmd === 'help' ? 0 : 1);
