#!/usr/bin/env php
<?php
/* ============================================================
   alpha-sports — the Python engine behind the Sports Analytics Lab add-on.

   The engine is the add-on's own FastAPI app (Addons/sports/<ver>/engine) run
   on the official python:3.12-slim image with a persistent virtual environment
   at <data>/venv — no image build (Docker 29 here has no buildx), and "bit by
   bit" means `add-lib <package>` pip-installs into that venv and restarts.
   The container is capped, read-only, unprivileged (uid 33), 127.0.0.1 only;
   the panel's PHP is its only client.

   ⚠️ The engine code the container mounts is a ROOT-OWNED COPY in
   /var/lib/alpha-sports/engine, synced from the add-on folder by `sync`
   (install/restart do it too) — the container never reads the webroot.
   ⚠️ ROOT-OWNED AND SELF-CONTAINED. Never includes anything from /var/www/alpha.

     status                 helper / docker / image / venv / service / health / settings / job
     preflight              what an install needs against what this server has now
     install                pull the base image, build the venv, start (background job)
     add-lib <package>      pip-install one more package into the venv, restart (background job)
     job <id>               state, progress and log tail of a job
     start | stop | restart the service (start also enables it at boot; stop disables it)
     sync                   refresh the root-owned engine copy from the add-on folder
     set <key> <value>      mem <MB 256-4096> · cpus <0.5-4>
     logs                   the container's last 150 log lines
     delete [image]         stop; remove the venv (outputs are kept); with "image" also the base image
     unit-text | unit-check the systemd unit this helper installs / whether the installed one matches

   The R runtime (for the catalogue's R packages) is a SECOND image, rocker/r-ver, with a
   persistent library at <data>/rlib. No service: R scripts run one-off, offline
   (--network none), read-only, as uid 33, from the root-owned engine copy's r/ folder.
     r-status               image / library / packages
     r-install              pull rocker/r-ver + install the base packages (background job)
     r-add <package>        install one CRAN package into the library, binaries via Posit PM (job)
     r-run <script> <json>  run engine/r/<script>.R with one JSON argument; prints its JSON
     r-delete [image]       remove the library; with "image" also the R image
   ============================================================ */

const ROOT     = '/var/www/alpha-private/sports';
const STATE    = '/var/lib/alpha-sports';
const ADDONS   = '/var/www/alpha/Addons/sports';
const IMAGE    = 'python:3.12-slim';
const NAME     = 'alpha-sports';
const UNIT     = 'alpha-sports';
const UNITFILE = '/etc/systemd/system/alpha-sports.service';
const ENVFILE  = '/etc/alpha-sports.env';
const PORT     = 8765;
const MAX_JOBS = 20;
const NEED_DISK_MB = 1400;   // base image ~150 MB + a scientific-Python venv ~900 MB, with room
const R_IMAGE  = 'rocker/r-ver:4.4';
const R_NAME   = 'alpha-sports-r';
const R_LIB    = ROOT . '/rlib';
// what the first R feature needs; r-add grows it. 'owner/repo' = GitHub (engsoccerdata left CRAN in 2023)
const R_BASE   = ['jsonlite', 'jalapic/engsoccerdata'];
const R_NEED_DISK_MB = 1200;                      // rocker/r-ver is ~800 MB unpacked

function fail($m, $c = 2) { fwrite(STDERR, $m . "\n"); exit($c); }
function out($a) { echo json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n"; exit(0); }
function sh($cmd) { $o = []; $rc = 1; @exec($cmd . ' 2>&1', $o, $rc); return [implode("\n", $o), $rc]; }
function bin($n) { [$o, $rc] = sh('command -v ' . escapeshellarg($n)); return $rc === 0 ? trim($o) : ''; }
function ensure_state() { foreach ([STATE, STATE . '/jobs', STATE . '/engine'] as $d) if (!is_dir($d)) { @mkdir($d, 0700, true); @chmod($d, 0700); } @chmod(STATE . '/engine', 0755); }
function meta($d) { $m = @json_decode((string) @file_get_contents("$d/meta.json"), true); return is_array($m) ? $m : []; }

/* ---------- jobs ---------- */
function job_state($d) {
  $rc = is_file("$d/rc") ? (int) trim((string) @file_get_contents("$d/rc")) : null;
  $pid = (int) trim((string) @file_get_contents("$d/pid"));
  if ($rc === null && $pid > 0 && @file_exists("/proc/$pid")) return ['running', null];
  return $rc === null ? ['lost', null] : ['done', $rc];
}
function job_dir($id) { if (!preg_match('/^[a-z0-9]{8,24}$/', (string) $id)) fail('bad job id'); $d = STATE . '/jobs/' . $id; if (!is_dir($d)) fail('no such job'); return $d; }
function clean_log($raw) {
  $raw = preg_replace('/\x1b\[[0-9;?]*[A-Za-z]/', "\n", (string) $raw);
  $raw = str_replace("\r", "\n", $raw);
  return preg_replace("/\n{2,}/", "\n", $raw);
}
function mb_of($n, $u) { return (float) $n * (['B' => 1 / 1048576, 'kB' => 1 / 1024, 'KB' => 1 / 1024, 'MB' => 1, 'GB' => 1024][$u] ?? 1); }
function pull_frac($log) {
  $layers = [];
  foreach (explode("\n", $log) as $l) {
    if (!preg_match('/^([0-9a-f]{12}): (.+)$/', trim($l), $m)) continue;
    $id = $m[1]; $s = $m[2]; $p = $layers[$id] ?? 0.0;
    if (preg_match('/^Downloading.*?([\d.]+)\s*([kMG]?B)\/([\d.]+)\s*([kMG]?B)/', $s, $x)) $p = 0.7 * min(1, mb_of($x[1], $x[2]) / max(0.001, mb_of($x[3], $x[4])));
    elseif (preg_match('/^(Verifying Checksum|Download complete)/', $s)) $p = max($p, 0.7);
    elseif (preg_match('/^Extracting/', $s)) $p = max($p, 0.85);
    elseif (preg_match('/^(Pull complete|Already exists)/', $s)) $p = 1.0;
    $layers[$id] = $p;
  }
  return $layers ? array_sum($layers) / count($layers) : 0.0;
}
/** Progress from the job's own markers: pull → venv → pip (Collecting lines) → install → start. */
function job_view($d) {
  $m = meta($d); [$st, $rc] = job_state($d);
  $raw = clean_log(is_file("$d/log") ? (string) @file_get_contents("$d/log") : '');
  $lines = array_values(array_filter(explode("\n", rtrim($raw)), fn($l) => !preg_match('/^[0-9a-f]{12}: (Downloading|Extracting|Waiting|Verifying)/', trim($l))));
  $tail = implode("\n", array_slice($lines, -60)); if (strlen($tail) > 9000) $tail = substr($tail, -9000);
  $elapsed = max(0, (is_file("$d/rc") ? (int) @filemtime("$d/rc") : time()) - (int) ($m['started'] ?? time()));
  $expect = (int) ($m['expect'] ?? 45);
  $collecting = preg_match_all('/^Collecting /m', $raw);
  $rdone = preg_match_all('/^\* DONE \(/m', $raw);
  if ($st === 'done') { $pct = $rc === 0 ? 100 : 0; $phase = $rc === 0 ? 'Finished' : 'Failed'; }
  elseif (strpos($raw, '== r-packages') !== false) { $pct = 30 + (int) round(65 * min(1, $rdone / max(1, $expect))); $phase = 'Installing R packages (' . $rdone . ' of ≈' . $expect . ' built)'; }
  elseif (strpos($raw, '== r-pull') !== false) { $pct = 2 + (int) round(pull_frac($raw) * 26); $phase = 'Downloading the R image (rocker/r-ver, ≈ 300 MB)'; }
  elseif (strpos($raw, '== start') !== false) { $pct = 97; $phase = 'Starting the engine'; }
  elseif (preg_match('/^Successfully installed/m', $raw)) { $pct = 94; $phase = 'Libraries installed'; }
  elseif (preg_match('/^Installing collected packages/m', $raw)) { $pct = 86; $phase = 'Installing the collected libraries'; }
  elseif ($collecting) { $pct = 30 + (int) round(54 * min(1, $collecting / max(1, $expect))); $phase = 'Resolving and downloading libraries (' . $collecting . ' so far)'; }
  elseif (strpos($raw, '== venv') !== false) { $pct = 27; $phase = 'Creating the Python environment'; }
  elseif (strpos($raw, '== pull') !== false) { $pct = 2 + (int) round(pull_frac($raw) * 23); $phase = 'Downloading the Python base image'; }
  else { $pct = 1; $phase = 'Starting'; }
  $eta = ($st === 'running' && $pct >= 5) ? (int) round($elapsed * (100 - $pct) / $pct) : null;
  return $m + ['state' => $st, 'rc' => $rc, 'pct' => $pct, 'phase' => $phase, 'eta' => $eta, 'elapsed' => $elapsed, 'tail' => $tail, 'load' => sys_load()];
}
function job_start($kind, $cmd, array $extra = []) {
  ensure_state();
  foreach (glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: [] as $d) { [$st] = job_state($d); if ($st === 'running') { $m = meta($d); out(['ok' => true, 'job' => $m['id'], 'already' => true]); } }
  $id = date('ymdHis') . substr(bin2hex(random_bytes(3)), 0, 6);
  $d = STATE . '/jobs/' . $id; @mkdir($d, 0700, true);
  file_put_contents("$d/meta.json", json_encode(['id' => $id, 'kind' => $kind, 'started' => time()] + $extra));
  $inner = 'echo $$ > ' . escapeshellarg("$d/pid") . '; ( ' . $cmd . ' ) > ' . escapeshellarg("$d/log") . ' 2>&1; echo $? > ' . escapeshellarg("$d/rc");
  exec('setsid nohup bash -c ' . escapeshellarg($inner) . ' > /dev/null 2>&1 &');
  $dirs = glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: []; rsort($dirs);
  foreach (array_slice($dirs, MAX_JOBS) as $old) { foreach (glob("$old/*") ?: [] as $f) @unlink($f); @rmdir($old); }
  return $id;
}
function running_job() { foreach (glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: [] as $d) { [$st] = job_state($d); if ($st === 'running') { $m = meta($d); return ['id' => $m['id'], 'kind' => $m['kind'], 'started' => $m['started'], 'pkg' => $m['pkg'] ?? '']; } } return null; }

/* ---------- server numbers ---------- */
function sys_load() {
  $la = array_map('floatval', array_slice(explode(' ', (string) @file_get_contents('/proc/loadavg')), 0, 3));
  $mi = (string) @file_get_contents('/proc/meminfo'); $kb = fn($k) => preg_match('/^' . $k . ':\s+(\d+)/m', $mi, $x) ? (int) $x[1] : 0;
  [$o] = sh('nproc');
  return ['load1' => $la[0] ?? 0, 'load5' => $la[1] ?? 0, 'cores' => max(1, (int) trim($o)), 'mem_total_mb' => intdiv($kb('MemTotal'), 1024), 'mem_avail_mb' => intdiv($kb('MemAvailable'), 1024)];
}
function free_mb($path) { [$o] = sh('df -Pm ' . escapeshellarg(is_dir($path) ? $path : '/') . ' | tail -1'); $p = preg_split('/\s+/', trim($o)); return (int) ($p[3] ?? 0); }
function docker_root() { [$root] = sh('docker info --format "{{.DockerRootDir}}"'); $root = trim($root); return $root !== '' ? $root : '/var/lib/docker'; }
function du_mb($path) { if (!is_dir($path)) return 0; [$o] = sh('du -sm ' . escapeshellarg($path)); return (int) trim(explode("\t", $o)[0] ?? '0'); }

/* ---------- settings, env, unit ---------- */
function settings() {
  $s = @json_decode((string) @file_get_contents(STATE . '/settings.json'), true); $s = is_array($s) ? $s : [];
  return ['mem' => (int) ($s['mem'] ?? 1024), 'cpus' => (float) ($s['cpus'] ?? 1.5)];
}
function env_text(array $s) {
  return "# Alpha Sports engine — written by /usr/local/sbin/alpha-sports (Settings in the add-on). Do not hand-edit.\n"
       . "IMAGE=" . IMAGE . "\nPORT=" . PORT . "\nMEM=" . (int) $s['mem'] . "m\n"
       . "CPUS=" . rtrim(rtrim(number_format($s['cpus'], 1, '.', ''), '0'), '.') . "\nDATA=" . ROOT . "\nENGINE=" . STATE . "/engine\n";
}
function write_settings(array $s) {
  ensure_state();
  file_put_contents(STATE . '/settings.json', json_encode($s)); @chmod(STATE . '/settings.json', 0600);
  file_put_contents(ENVFILE, env_text($s)); @chmod(ENVFILE, 0644);
}
function unit_text() {
  return "[Unit]\n"
       . "Description=Alpha Sports analytics engine (Alpha Panel add-on)\n"
       . "After=docker.service network-online.target\n"
       . "Requires=docker.service\n"
       . "# without its venv (not installed yet, or deleted to free space) it must give up, not retry for ever\n"
       . "StartLimitIntervalSec=120\n"
       . "StartLimitBurst=3\n\n"
       . "[Service]\n"
       . "Type=simple\n"
       . "EnvironmentFile=/etc/alpha-sports.env\n"
       . "ExecStartPre=-/usr/bin/docker rm -f alpha-sports\n"
       . "# 127.0.0.1 only: the panel is the one client. Read-only root, no capabilities, uid 33; the venv lives on the data volume.\n"
       . "ExecStart=/usr/bin/docker run --rm --pull never --name alpha-sports --init --user 33:33 --memory \${MEM} --cpus \${CPUS} --pids-limit 512 \\\n"
       . "  --cap-drop ALL --security-opt no-new-privileges --read-only --tmpfs /tmp:size=256m -e HOME=/data -e MPLCONFIGDIR=/tmp/mpl -e SPORTS_DATA=/data \\\n"
       . "  -v \${DATA}:/data -v \${ENGINE}:/engine:ro -p 127.0.0.1:\${PORT}:8000 \${IMAGE} \\\n"
       . "  /data/venv/bin/python -m uvicorn --app-dir /engine app:app --host 0.0.0.0 --port 8000 --workers 1 --no-access-log\n"
       . "# '-': after a stop from outside the container is already gone; a failing ExecStop would count as a crash\n"
       . "ExecStop=-/usr/bin/docker stop -t 10 alpha-sports\n"
       . "SuccessExitStatus=143 137\n"
       . "Restart=on-failure\n"
       . "RestartSec=5\n\n"
       . "[Install]\n"
       . "WantedBy=multi-user.target\n";
}
function ensure_unit() {
  if (!is_file(UNITFILE) || file_get_contents(UNITFILE) !== unit_text()) { file_put_contents(UNITFILE, unit_text()); @chmod(UNITFILE, 0644); sh('systemctl daemon-reload'); }
  if (!is_file(ENVFILE) || file_get_contents(ENVFILE) !== env_text(settings())) write_settings(settings());
}
function ensure_tree() {
  foreach ([ROOT, ROOT . '/out', ROOT . '/cache'] as $d) if (!is_dir($d)) @mkdir($d, 0750, true);
  sh('chown www-data:www-data ' . implode(' ', array_map('escapeshellarg', [ROOT, ROOT . '/out', ROOT . '/cache'])));
  @chmod(ROOT, 0750);
}
/** The add-on's newest engine folder (versions sort semver-ish). */
function addon_engine_dir() {
  $vs = array_filter(glob(ADDONS . '/v*', GLOB_ONLYDIR) ?: [], fn($d) => is_file("$d/engine/app.py"));
  usort($vs, fn($a, $b) => version_compare(ltrim(basename($b), 'v'), ltrim(basename($a), 'v')));
  return $vs[0] ?? '';
}
/** Root-owned copy of the engine: the container mounts this, never the webroot. */
function sync_engine() {
  ensure_state();
  $src = addon_engine_dir();
  if ($src === '') return ['ok' => false, 'msg' => 'The add-on ships no engine (Addons/sports/v*/engine/app.py).'];
  $tmp = STATE . '/engine.new'; sh('rm -rf ' . escapeshellarg($tmp));
  @mkdir($tmp, 0755, true);
  [$o, $rc] = sh('cp -a ' . escapeshellarg("$src/engine/.") . ' ' . escapeshellarg($tmp . '/'));
  if ($rc !== 0) return ['ok' => false, 'msg' => 'Could not copy the engine: ' . $o];
  sh('find ' . escapeshellarg($tmp) . ' -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null');
  sh('chown -R root:root ' . escapeshellarg($tmp) . ' && chmod -R u=rwX,go=rX ' . escapeshellarg($tmp));
  sh('rm -rf ' . escapeshellarg(STATE . '/engine') . ' && mv ' . escapeshellarg($tmp) . ' ' . escapeshellarg(STATE . '/engine'));
  $ver = basename($src);
  file_put_contents(STATE . '/engine.version', $ver . "\n");
  return ['ok' => true, 'version' => $ver, 'files' => count(glob(STATE . '/engine/*'))];
}
function image_present() { [, $rc] = sh('docker image inspect ' . IMAGE . ' >/dev/null'); return $rc === 0; }
function image_mb() { [$sz] = sh('docker images ' . IMAGE . ' --format "{{.Size}}"'); return preg_match('/^([\d.]+)\s*([kKMG]?B)/', trim($sz), $m) ? (int) round(mb_of($m[1], $m[2] === 'KB' ? 'kB' : $m[2])) : 0; }
// ⚠️ not venv/bin/python: inside the venv that is a SYMLINK to the container's interpreter, which does not
// exist on the host — is_file() says false and every verb then claims the engine is not installed.
function venv_present() { return is_file(ROOT . '/venv/pyvenv.cfg') && is_file(ROOT . '/venv/bin/uvicorn'); }
function unit_active() { [$o] = sh('systemctl is-active ' . UNIT); return trim($o) === 'active'; }
function health() {
  [$o, $rc] = sh('curl -fsS --max-time 3 http://127.0.0.1:' . PORT . '/health');
  $j = $rc === 0 ? json_decode($o, true) : null;
  return is_array($j) ? $j : null;
}
function container_mem_mb() { [$o, $rc] = sh('docker stats --no-stream --format "{{.MemUsage}}" ' . NAME); return $rc === 0 && preg_match('/^([\d.]+)\s*([kKMG]i?B)/', trim($o), $m) ? (int) round(mb_of($m[1], str_replace('i', '', $m[2]) === 'KB' ? 'kB' : str_replace('i', '', $m[2]))) : 0; }
function pip_run($cmd) {
  // one-off container that can WRITE /data (the venv) — unlike the service, which is read-only and only mounts it
  return 'docker run --rm --user 33:33 --init --memory 1536m --cpus 2 --pids-limit 512 --cap-drop ALL --security-opt no-new-privileges'
       . ' -e HOME=/data -e PIP_DISABLE_PIP_VERSION_CHECK=1 -v ' . ROOT . ':/data -v ' . STATE . '/engine:/engine:ro ' . IMAGE . ' sh -c ' . escapeshellarg($cmd);
}
function expect_count() {
  $req = STATE . '/engine/requirements.txt';
  $n = 0; foreach (@file($req, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $l) if (!preg_match('/^\s*#/', $l)) $n++;
  return max(10, $n * 5);   // each requirement pulls ~5 transitive packages
}

/* ---------- R runtime ---------- */
function r_image_present() { [, $rc] = sh('docker image inspect ' . R_IMAGE . ' >/dev/null'); return $rc === 0; }
function r_image_mb() { [$sz] = sh('docker images ' . R_IMAGE . ' --format "{{.Size}}"'); return preg_match('/^([\d.]+)\s*([kKMG]?B)/', trim($sz), $m) ? (int) round(mb_of($m[1], $m[2] === 'KB' ? 'kB' : $m[2])) : 0; }
function r_pkgs() {
  $out = [];
  foreach (glob(R_LIB . '/*', GLOB_ONLYDIR) ?: [] as $d) {
    $desc = @file_get_contents($d . '/DESCRIPTION');
    $v = preg_match('/^Version:\s*(\S+)/m', (string) $desc, $m) ? $m[1] : '';
    $out[basename($d)] = $v;
  }
  ksort($out);
  return $out;
}
function r_present() { return r_image_present() && is_dir(R_LIB) && isset(r_pkgs()['jsonlite']); }
function r_run_cmd($extra, $cmd, $net = 'none') {
  return 'docker run --rm --user 33:33 --init --memory 1024m --cpus 1.5 --pids-limit 256 --cap-drop ALL --security-opt no-new-privileges'
       . ' --read-only --tmpfs /tmp:size=256m --network ' . $net . ' -e HOME=/data -e R_LIBS_USER=/data/rlib -e TMPDIR=/tmp'
       . ' -v ' . ROOT . ':/data -v ' . STATE . '/engine/r:/engine/r:ro ' . $extra . ' ' . R_IMAGE . ' ' . $cmd;
}
function r_install_expr(array $pkgs) {
  // CRAN names install as Posit Package Manager BINARIES (rocker/r-ver's default repo);
  // owner/repo names come from GitHub through remotes (pure R, so it installs anywhere)
  $cran = []; $gh = [];
  foreach ($pkgs as $p) { if (strpos($p, '/') !== false) $gh[] = $p; else $cran[] = $p; }
  $q = fn($x) => '"' . $x . '"';
  $expr = '.libPaths(c("/data/rlib", .libPaths())); dir.create("/data/rlib", showWarnings=FALSE); ';
  if ($cran) $expr .= 'install.packages(c(' . implode(',', array_map($q, $cran)) . '), lib="/data/rlib", dependencies=c("Depends","Imports"), Ncpus=2); ';
  if ($gh) {
    $expr .= 'if (!requireNamespace("remotes", quietly=TRUE)) install.packages("remotes", lib="/data/rlib"); ';
    foreach ($gh as $g) $expr .= 'remotes::install_github(' . $q($g) . ', lib="/data/rlib", upgrade="never", dependencies=c("Depends","Imports"), build_vignettes=FALSE); ';
  }
  $names = array_merge($cran, array_map(fn($g) => basename($g), $gh));
  $expr .= 'ok <- sapply(c(' . implode(',', array_map($q, $names)) . '), requireNamespace, quietly=TRUE); print(ok); if (!all(ok)) quit(status=1)';
  return $expr;
}

/* ============================================================ verbs */
$verb = $argv[1] ?? ''; $arg = $argv[2] ?? ''; $arg2 = $argv[3] ?? '';
if (posix_geteuid() !== 0) fail('alpha-sports must run as root (via sudo)');
$docker = bin('docker') !== '';

switch ($verb) {
  case 'status': {
    ensure_state();
    $s = settings(); $act = $docker && unit_active(); $h = $act ? health() : null;
    out(['ok' => true, 'docker' => $docker, 'image' => $docker && image_present(), 'image_mb' => $docker ? image_mb() : 0,
         'venv' => venv_present(), 'venv_mb' => du_mb(ROOT . '/venv'), 'outputs_mb' => du_mb(ROOT . '/out'),
         'installed' => $docker && image_present() && venv_present(),
         'active' => $act, 'healthy' => $h !== null, 'health' => $h, 'mem_mb' => $act ? container_mem_mb() : 0,
         'engine_version' => trim((string) @file_get_contents(STATE . '/engine.version')), 'addon_version' => basename(addon_engine_dir()),
         'unit_ok' => is_file(UNITFILE) && file_get_contents(UNITFILE) === unit_text(),
         'settings' => $s, 'port' => PORT, 'running' => running_job(), 'load' => sys_load(),
         'r' => ['image' => $docker && r_image_present(), 'image_mb' => $docker ? r_image_mb() : 0, 'lib' => is_dir(R_LIB), 'lib_mb' => du_mb(R_LIB),
                 'installed' => $docker && r_present(), 'pkgs' => r_pkgs(), 'scripts' => array_map(fn($f) => basename($f, '.R'), glob(STATE . '/engine/r/*.R') ?: [])]]);
  }
  case 'r-status': {
    out(['ok' => true, 'docker' => $docker, 'image' => $docker && r_image_present(), 'image_mb' => $docker ? r_image_mb() : 0, 'lib' => is_dir(R_LIB), 'lib_mb' => du_mb(R_LIB),
         'installed' => $docker && r_present(), 'pkgs' => r_pkgs(), 'need_mb' => max(0, R_NEED_DISK_MB - ($docker && r_image_present() ? r_image_mb() : 0)),
         'docker_free_mb' => free_mb($docker ? docker_root() : '/var/lib/docker'), 'running' => running_job()]);
  }
  case 'r-install': {
    if (!$docker) fail('docker is not installed');
    ensure_tree(); $sy = sync_engine(); if (empty($sy['ok'])) fail($sy['msg']);
    if (!is_dir(R_LIB)) { @mkdir(R_LIB, 0750, true); sh('chown www-data:www-data ' . escapeshellarg(R_LIB)); }
    $cmd = 'echo "== r-pull"; docker image inspect ' . R_IMAGE . ' >/dev/null 2>&1 || docker pull ' . R_IMAGE . ' || exit 1; '
         . 'echo "== r-packages"; ' . r_run_cmd('', 'Rscript -e ' . escapeshellarg(r_install_expr(R_BASE)), 'bridge') . ' || exit 1; '
         . 'echo "== done"; echo "R runtime ready: $(ls ' . R_LIB . ' | wc -l) packages"';
    $id = job_start('r-install', $cmd, ['expect' => count(R_BASE) + 2]);
    out(['ok' => true, 'job' => $id]);
  }
  case 'r-add': {
    if (!$docker) fail('docker is not installed');
    if (!r_present()) fail('install the R runtime first');
    $pkg = trim($arg);
    if (!preg_match('/^[A-Za-z][A-Za-z0-9.]{1,60}$/', $pkg) && !preg_match('/^[A-Za-z0-9_.-]{1,60}\/[A-Za-z0-9_.-]{1,80}$/', $pkg)) fail('that is not a CRAN package name or a GitHub owner/repo');
    $cmd = 'echo "== r-packages"; ' . r_run_cmd('', 'Rscript -e ' . escapeshellarg(r_install_expr([$pkg])), 'bridge') . ' || exit 1; echo "== done"';
    $id = job_start('r-add', $cmd, ['expect' => 6, 'pkg' => $pkg]);
    out(['ok' => true, 'job' => $id, 'pkg' => $pkg]);
  }
  case 'r-run': {
    if (!$docker) fail('docker is not installed');
    if (!r_present()) fail('the R runtime is not installed');
    $script = trim($arg);
    if (!preg_match('/^[a-z0-9_-]{1,40}$/', $script) || !is_file(STATE . '/engine/r/' . $script . '.R')) fail('no such R script');
    $json = (string) $arg2;
    if (strlen($json) > 4000 || !is_array(json_decode($json, true))) fail('the argument must be a JSON object under 4000 bytes');
    [$o, $rc] = sh('timeout 150 ' . r_run_cmd('', 'Rscript /engine/r/' . $script . '.R ' . escapeshellarg($json)));
    $lines = array_values(array_filter(explode("\n", $o), fn($l) => trim($l) !== ''));
    $last = end($lines); $j = json_decode((string) $last, true);
    if (!is_array($j)) fail('R did not answer with JSON (exit ' . $rc . '): ' . substr($o, -600));
    out($j + ['ok' => $j['ok'] ?? true]);
  }
  case 'r-delete': {
    $freed = du_mb(R_LIB);
    sh('rm -rf ' . escapeshellarg(R_LIB));
    if ($arg === 'image' && $docker) {
      [$users] = sh('docker ps -a --filter ancestor=' . R_IMAGE . ' --format "{{.Names}}"');
      if (trim($users) === '') { $freed += r_image_mb(); sh('docker image rm ' . R_IMAGE); }
    }
    out(['ok' => true, 'freed_mb' => $freed]);
  }
  case 'preflight': {
    $root = $docker ? docker_root() : '/var/lib/docker';
    [$o, $rc] = sh('curl -fsSI --max-time 6 https://pypi.org/simple/ -o /dev/null');
    $l = sys_load();
    $need = NEED_DISK_MB - ($docker && image_present() ? image_mb() : 0) - du_mb(ROOT . '/venv');
    out(['ok' => true, 'docker' => $docker, 'docker_running' => $docker && sh('docker info >/dev/null')[1] === 0,
         'image' => $docker && image_present(), 'need_mb' => max(0, $need), 'docker_free_mb' => free_mb($root), 'data_free_mb' => free_mb(dirname(ROOT)),
         'docker_root' => $root, 'internet' => $rc === 0, 'engine' => addon_engine_dir() !== ''] + $l);
  }
  case 'install': {
    if (!$docker) fail('docker is not installed');
    ensure_tree(); ensure_unit();
    $sy = sync_engine(); if (empty($sy['ok'])) fail($sy['msg']);
    $cmd = 'echo "== pull"; docker image inspect ' . IMAGE . ' >/dev/null 2>&1 || docker pull ' . IMAGE . ' || exit 1; '
         . 'echo "== venv"; ' . pip_run('set -e; [ -x /data/venv/bin/python ] || python -m venv /data/venv; /data/venv/bin/pip install --no-cache-dir --progress-bar off --upgrade pip >/dev/null; /data/venv/bin/pip install --no-cache-dir --progress-bar off -r /engine/requirements.txt') . ' || exit 1; '
         . 'echo "== start"; systemctl reset-failed ' . UNIT . ' 2>/dev/null; systemctl enable --now ' . UNIT . ' && for i in 1 2 3 4 5 6 7 8 9 10 11 12; do sleep 2; curl -fsS http://127.0.0.1:' . PORT . '/health >/dev/null 2>&1 && { echo "engine answering"; exit 0; }; done; echo "engine did not answer within 24s"; docker logs --tail 40 ' . NAME . '; exit 1';
    $id = job_start('install', $cmd, ['expect' => expect_count()]);
    out(['ok' => true, 'job' => $id]);
  }
  case 'add-lib': {
    if (!$docker) fail('docker is not installed');
    if (!venv_present()) fail('install the engine first');
    $pkg = trim($arg);
    // a PyPI requirement, or gh:owner/repo → GitHub's HEAD archive (pip builds it; no git in the image)
    if (preg_match('/^gh:([A-Za-z0-9_.-]{1,60}\/[A-Za-z0-9_.-]{1,80})$/', $pkg, $m)) $spec = 'https://github.com/' . $m[1] . '/archive/HEAD.zip';
    elseif (preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,60}(\[[A-Za-z0-9,_-]+\])?([<>=!~]=?[0-9A-Za-z.*]+)?$/', $pkg)) $spec = $pkg;
    else fail('that is not a package name (PyPI requirement or gh:owner/repo)');
    $cmd = 'echo "== venv"; ' . pip_run('/data/venv/bin/pip install --no-cache-dir --progress-bar off ' . escapeshellarg($spec)) . ' || exit 1; '
         . 'echo "== start"; systemctl reset-failed ' . UNIT . ' 2>/dev/null; systemctl restart ' . UNIT . '; for i in 1 2 3 4 5 6 7 8 9 10; do sleep 2; curl -fsS http://127.0.0.1:' . PORT . '/health >/dev/null 2>&1 && { echo "engine answering"; exit 0; }; done; echo "engine did not come back"; exit 1';
    $id = job_start('add-lib', $cmd, ['expect' => 8, 'pkg' => $pkg]);
    out(['ok' => true, 'job' => $id, 'pkg' => $pkg]);
  }
  case 'job': out(['ok' => true] + job_view(job_dir($arg)));
  case 'sync': { $r = sync_engine(); if (empty($r['ok'])) fail($r['msg']); out($r); }
  case 'start': {
    if (!venv_present()) fail('install the engine first');
    ensure_tree(); ensure_unit(); $sy = sync_engine(); if (empty($sy['ok'])) fail($sy['msg']);
    sh('systemctl reset-failed ' . UNIT);   // four quick restarts (add-lib ×4) trip StartLimitBurst; a stale 'failed' must not block a deliberate start
    [$o, $rc] = sh('systemctl enable --now ' . UNIT); if ($rc !== 0) fail('could not start: ' . $o);
    out(['ok' => true]);
  }
  case 'stop': { sh('systemctl disable --now ' . UNIT); out(['ok' => true]); }
  case 'restart': {
    if (!venv_present()) fail('install the engine first');
    ensure_unit(); $sy = sync_engine(); if (empty($sy['ok'])) fail($sy['msg']);
    sh('systemctl reset-failed ' . UNIT);
    [$o, $rc] = sh('systemctl restart ' . UNIT); if ($rc !== 0) fail('could not restart: ' . $o);
    out(['ok' => true, 'engine' => $sy['version']]);
  }
  case 'set': {
    $s = settings();
    if ($arg === 'mem') { $v = (int) $arg2; if ($v < 256 || $v > 4096) fail('memory must be 256–4096 MB'); $s['mem'] = $v; }
    elseif ($arg === 'cpus') { $v = (float) $arg2; if ($v < 0.5 || $v > 4) fail('cpus must be 0.5–4'); $s['cpus'] = round($v, 1); }
    else fail('unknown setting');
    write_settings($s);
    out(['ok' => true, 'settings' => $s, 'restart_needed' => unit_active()]);
  }
  case 'logs': { [$o] = sh('docker logs --tail 150 ' . NAME); out(['ok' => true, 'log' => $o]); }
  case 'delete': {
    sh('systemctl disable --now ' . UNIT);
    $freed = du_mb(ROOT . '/venv');
    sh('rm -rf ' . escapeshellarg(ROOT . '/venv'));
    if ($arg === 'image' && $docker) {
      [$users] = sh('docker ps -a --filter ancestor=' . IMAGE . ' --format "{{.Names}}"');
      if (trim($users) === '') { $freed += image_mb(); sh('docker image rm ' . IMAGE); }
    }
    out(['ok' => true, 'freed_mb' => $freed]);
  }
  case 'unit-text': { echo unit_text(); exit(0); }
  case 'unit-check': out(['ok' => is_file(UNITFILE) && file_get_contents(UNITFILE) === unit_text(), 'installed' => is_file(UNITFILE)]);
  default:
    fail("usage: alpha-sports status|preflight|install|add-lib <pkg>|job <id>|start|stop|restart|sync|set <mem|cpus> <v>|logs|delete [image]|unit-text|unit-check|r-status|r-install|r-add <pkg>|r-run <script> <json>|r-delete [image]");
}
