#!/usr/bin/env php
<?php
/* ============================================================
   alpha-pencil — Pencil (evolus/pencil, GPL-2.0) for the panel's add-on.

   Pencil is a DESKTOP app (Electron) for GUI mock-ups and prototypes, not a web
   app. To use it from the panel it runs in a container with its own display:

       Xvnc (TigerVNC: an X server that is also a VNC server, localhost only)
         → openbox (keeps Pencil's main window maximised, no title bar)
         → Pencil 3.1.1 (the official .deb, run as uid 33 with --no-sandbox)
       websockify on :6080 serves the noVNC web client and bridges it to Xvnc

   The container publishes 6080 on 127.0.0.1 only; the ADMIN vhost proxies
   /pencil/ to it behind the panel session (auth_request), WebSocket included.
   The container is started on demand (systemd unit alpha-pencil.service) and
   stopped again by `idle-check` (cron) once nobody has been connected for the
   configured number of minutes — a desktop app that nobody is looking at
   should not hold half a gigabyte of RAM.

   Documents live on the host in /var/www/alpha-private/pencil/Documents
   (mounted at /data/Documents; Pencil's home is /data/home). Nothing else of
   the host is visible inside.

   ⚠️ ROOT-OWNED AND SELF-CONTAINED. Never includes anything from /var/www/alpha.
   ⚠️ nginx is only ever reloaded after `nginx -t` passes.
   ⚠️ The unit text below is the one provisioning/systemd/alpha-pencil.service
      ships; `unit-check` reports a difference.

     status                  image / unit / running / memory / settings / nginx
     preflight               what a build needs (disk, memory, CPU, download) against what this server has now
     install | update        build the image (background job); update re-pulls the base for security fixes
     delete-image            stop the studio and delete its image (documents are kept); reports the space freed
     job <id>                state + log tail of a job
     start | stop | restart  the container (through systemd)
     set <key> <value>       idle <min 0-1440> · mem <MB 768-8192> · cpus <0.5-4> · res <WxH>
     idle-check              (cron) stop the container when idle for longer than `idle`
     unit-check              is the installed unit the expected one
     nginx                   (re)install the location snippet + vhost include, reload
     remove                  stop, drop the image and the location (documents are kept)
   ============================================================ */

const ROOT     = '/var/www/alpha-private/pencil';
const STATE    = '/var/lib/alpha-pencil';
const VERSION  = '3.1.1';
const DEB_URL  = 'https://pencil.evolus.vn/dl/V3.1.1.ga/Pencil_3.1.1.ga_amd64.deb';
const IMAGE    = 'alpha/pencil:3.1.1';
const NAME     = 'alpha-pencil';
const UNIT     = 'alpha-pencil';
const UNITFILE = '/etc/systemd/system/alpha-pencil.service';
const ENVFILE  = '/etc/alpha-pencil.env';
const PORT     = 3092;
const BASE     = '/pencil/';
const SNIPDIR  = '/etc/nginx/snippets/alpha-admin.d';
const SNIPPET  = SNIPDIR . '/pencil.conf';
const MAX_JOBS = 20;
const RES_OK   = ['1280x720', '1366x768', '1440x900', '1600x900', '1920x1080', '2560x1440'];

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 . '/build'] as $d) if (!is_dir($d)) { @mkdir($d, 0700, true); @chmod($d, 0700); } }
function meta($d) { $m = @json_decode((string) @file_get_contents("$d/meta.json"), true); return is_array($m) ? $m : []; }
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 job_view($d) {
  $m = meta($d); [$st, $rc] = job_state($d);
  $orig = is_file("$d/log") ? (string) @file_get_contents("$d/log") : '';
  $raw = preg_replace('/^.*\r(?!\n)/m', '', $orig);
  $lines = explode("\n", rtrim($raw)); $tail = implode("\n", array_slice($lines, -60)); if (strlen($tail) > 9000) $tail = substr($tail, -9000);
  $step = null; if (preg_match_all('/^Step (\d+)\/(\d+) :/m', $raw, $mm)) $step = [(int) end($mm[1]), (int) end($mm[2])];
  // the UNcollapsed log: curl redraws its bar with \r, and a read that lands mid-redraw must still see the percentages
  [$pct, $phase] = build_progress($orig, $st, $rc);
  $elapsed = max(0, (is_file("$d/rc") ? (int) @filemtime("$d/rc") : time()) - (int) ($m['started'] ?? time()));
  // a finished, successful build is remembered so the next estimate starts from a real number
  // a cached rebuild takes seconds and would make the next estimate absurd: only a real build is remembered
  if ($st === 'done' && $rc === 0 && !is_file("$d/recorded") && $elapsed > 60 && strpos($raw, '---> Using cache') === false) { @file_put_contents(STATE . '/last-build', (string) $elapsed); @touch("$d/recorded"); }
  $eta = null;
  if ($st === 'running' && $pct >= 4) $eta = (int) round($elapsed * (100 - $pct) / $pct);
  elseif ($st === 'running' && ($lb = (int) @file_get_contents(STATE . '/last-build')) > 0) $eta = max(0, $lb - $elapsed);
  return $m + ['state' => $st, 'rc' => $rc, 'step' => $step, 'pct' => $pct, 'phase' => $phase, 'eta' => $eta, 'elapsed' => $elapsed, 'tail' => $tail, 'ended' => is_file("$d/rc") ? (int) @filemtime("$d/rc") : 0, 'load' => sys_load()];
}
/**
 * Build progress from the classic builder's log. Step counting alone is useless here — step 4 (apt)
 * is nine tenths of the time — so that step is followed through apt's own output: packages fetched
 * against the "N newly installed" it announced, then packages set up, for each of its two installs
 * (the system packages, then Pencil's .deb and its dependencies).
 */
function build_progress($raw, $st, $rc) {
  /* Weights follow a measured cold build on this box (229 s): packages download 0:04–0:35, set up until
     1:36, the Pencil .deb downloads until 2:36, installs until 3:07, the layer is saved and the last
     steps run until 3:49. A percentage proportional to time is what makes the time-left estimate honest. */
  if ($st === 'done') return [$rc === 0 ? 100 : 0, $rc === 0 ? 'Finished' : 'Failed'];
  if (!preg_match_all('/^Step (\d+)\/(\d+) :/m', $raw, $mm)) return [1, 'Preparing'];
  $step = (int) end($mm[1]); $of = (int) end($mm[2]);
  if ($step < 4) return [min(3, $step), $step === 1 ? 'Downloading the Debian base image' : 'Preparing the build'];
  if ($step > 4) return [min(99, 90 + (int) round(($step - 4) / max(1, $of - 4) * 9)), 'Finishing the image'];
  $apt = substr($raw, strrpos($raw, 'Step 4/'));
  if (strpos($apt, '---> Using cache') !== false) return [88, 'Reusing the cached build'];
  preg_match_all('/(\d+) newly installed/', $apt, $ni);
  $installs = $ni[1] ?? [];
  if (!$installs) return [4, 'Reading the package lists'];
  $parts = preg_split('/\d+ newly installed/', $apt);
  if (count($installs) === 1) {
    $n = max(1, (int) $installs[0]); $body = $parts[1] ?? '';
    $got = preg_match_all('/^Get:\d+ /m', $body); $set = preg_match_all('/^Setting up /m', $body);
    if ($set === 0) return [6 + (int) round(min(1, $got / $n) * 10), 'Downloading packages (' . min($got, $n) . ' of ' . $n . ')'];
    if ($set < $n) return [16 + (int) round($set / $n * 26), 'Installing packages (' . $set . ' of ' . $n . ')'];
    $after = ($i = strrpos($body, 'Downloading Pencil')) !== false ? substr($body, $i) : '';
    // the highest percentage seen, never the last fragment: a download only moves forward
    $dl = ($after !== '' && preg_match_all('/(\d{1,3}(?:\.\d)?)%/', $after, $m)) ? min(100.0, max(array_map('floatval', $m[1]))) : 0.0;
    return [42 + (int) round($dl / 100 * 24), $after === '' ? 'Finishing the system packages' : 'Downloading Pencil ' . VERSION . ' (' . (int) $dl . '%)'];
  }
  $n = max(1, (int) end($installs)); $set = preg_match_all('/^Setting up /m', end($parts));
  if ($set < $n) return [66 + (int) round($set / $n * 14), 'Installing Pencil (' . $set . ' of ' . $n . ')'];
  return [82, 'Saving the image layer'];
}
/** Load, memory and CPU count right now — shown before a build and while it runs. */
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, 'load15' => $la[2] ?? 0, 'cores' => max(1, (int) trim($o)),
          'mem_total_mb' => intdiv($kb('MemTotal'), 1024), 'mem_avail_mb' => intdiv($kb('MemAvailable'), 1024)];
}
function docker_root_free_mb() {
  [$root] = sh('docker info --format "{{.DockerRootDir}}"'); $root = trim($root) ?: '/var/lib/docker';
  [$o] = sh('df -Pm ' . escapeshellarg(is_dir($root) ? $root : '/') . ' | tail -1');
  $p = preg_split('/\s+/', trim($o));
  return [(int) ($p[3] ?? 0), $root];
}
function job_start($kind, $cmd) {
  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()]));
  $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;
}

/* ---------- settings (root-owned; the container cannot reach them) ---------- */
function settings() {
  $s = @json_decode((string) @file_get_contents(STATE . '/settings.json'), true);
  $s = is_array($s) ? $s : [];
  return ['idle' => (int) ($s['idle'] ?? 30), 'mem' => (int) ($s['mem'] ?? 1536), 'cpus' => (float) ($s['cpus'] ?? 2), 'res' => in_array($s['res'] ?? '', RES_OK, true) ? $s['res'] : '1600x900'];
}
function env_text(array $s) {
  return "# Pencil studio container — written by /usr/local/sbin/alpha-pencil (Settings in the add-on). Do not hand-edit.\n"
       . "IMAGE=" . IMAGE . "\nPORT=" . PORT . "\nMEM=" . (int) $s['mem'] . "m\nCPUS=" . rtrim(rtrim(number_format($s['cpus'], 1, '.', ''), '0'), '.') . "\nRES=" . $s['res'] . "\nDATA=" . ROOT . "\n";
}
function write_settings(array $s) {
  file_put_contents(STATE . '/settings.json', json_encode($s)); @chmod(STATE . '/settings.json', 0600);
  file_put_contents(ENVFILE, env_text($s)); @chmod(ENVFILE, 0644);
}

/* ---------- the systemd unit (identical to provisioning/systemd/alpha-pencil.service) ---------- */
function unit_text() {
  return "[Unit]\n"
       . "Description=Pencil prototyping studio (Alpha Panel add-on, started on demand)\n"
       . "After=docker.service network-online.target\n"
       . "Requires=docker.service\n"
       . "# without its image (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-pencil.env\n"
       . "ExecStartPre=-/usr/bin/docker rm -f alpha-pencil\n"
       . "# 127.0.0.1 only: the panel's nginx is the one way in. uid 33 = www-data on both sides, so documents stay www-data's.\n"
       . "ExecStart=/usr/bin/docker run --rm --pull never --name alpha-pencil --init --user 33:33 --memory \${MEM} --cpus \${CPUS} --pids-limit 768 \\\n"
       . "  --cap-drop ALL --security-opt no-new-privileges --shm-size 256m -e RES=\${RES} \\\n"
       . "  -p 127.0.0.1:\${PORT}:6080 -v \${DATA}/home:/data/home -v \${DATA}/Documents:/data/Documents \${IMAGE}\n"
       . "# '-': after a stop from outside the container is already gone; a failing ExecStop would count as a crash and restart it\n"
       . "ExecStop=-/usr/bin/docker stop -t 10 alpha-pencil\n"
       . "Restart=on-failure\n"
       . "# docker stop from anywhere (the Container Manager, a shell) ends the entrypoint with SIGTERM: that is a stop, not a crash\n"
       . "SuccessExitStatus=143 137\n"
       . "RestartSec=5\n"
       . "TimeoutStartSec=120\n\n"
       . "[Install]\n"
       . "WantedBy=multi-user.target\n";
}

/* ---------- the image ---------- */
function build_files() {
  $docker = "# Alpha Panel · Pencil studio image — written by /usr/local/sbin/alpha-pencil.\n"
    . "FROM debian:bookworm-slim\n"
    . "ARG PENCIL_URL\n"
    . "ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8\n"
    . "RUN apt-get update \\\n"
    . " && apt-get install -y --no-install-recommends ca-certificates curl tigervnc-standalone-server openbox novnc websockify python3-numpy \\\n"
    . "      fonts-dejavu-core fonts-liberation2 fonts-noto-core fonts-noto-color-emoji xdg-utils procps dbus-x11 \\\n"
    . "      libgtk-3-0 libnss3 libasound2 libgbm1 libxss1 libsecret-1-0 libdrm2 libxshmfence1 \\\n"
    . " && echo 'Downloading Pencil' && curl -fL --retry 3 -# -o /tmp/pencil.deb \"\$PENCIL_URL\" \\\n"
    . " && apt-get install -y --no-install-recommends /tmp/pencil.deb \\\n"
    . " && rm -f /tmp/pencil.deb && apt-get clean && rm -rf /var/lib/apt/lists/*\n"
    . "COPY entrypoint.sh openbox-rc.xml /opt/alpha/\n"
    . "COPY index.html /usr/share/novnc/index.html\n"
    . "RUN chmod 0755 /opt/alpha/entrypoint.sh && chmod 0644 /opt/alpha/openbox-rc.xml /usr/share/novnc/index.html\n"
    . "LABEL alpha.addon=\"pencil\" alpha.pencil.version=\"" . VERSION . "\"\n"
    . "EXPOSE 6080\n"
    . "USER 33:33\n"
    . "ENTRYPOINT [\"/opt/alpha/entrypoint.sh\"]\n";

  $entry = <<<'SH'
#!/bin/bash
# Alpha Panel · Pencil studio: Xvnc (localhost only) → openbox → Pencil; websockify serves noVNC on :6080.
set -u
export HOME=/data/home DISPLAY=:1 LANG=C.UTF-8 XDG_RUNTIME_DIR=/tmp/xdg-33
mkdir -p "$HOME/.config/gtk-3.0" /data/Documents "$XDG_RUNTIME_DIR"; chmod 700 "$XDG_RUNTIME_DIR"
[ -e "$HOME/Documents" ] || ln -s /data/Documents "$HOME/Documents"
# the file dialogs open on the folder the panel lists
grep -qs 'file:///data/Documents' "$HOME/.config/gtk-3.0/bookmarks" || echo 'file:///data/Documents Documents (saved on the server)' >> "$HOME/.config/gtk-3.0/bookmarks"
RES="${RES:-1600x900}"
Xvnc :1 -geometry "$RES" -depth 24 -SecurityTypes None -localhost -rfbport 5901 -AlwaysShared -AcceptSetDesktopSize=1 -desktop Pencil -nolisten tcp &
for i in $(seq 1 100); do [ -S /tmp/.X11-unix/X1 ] && break; sleep 0.1; done
openbox --config-file /opt/alpha/openbox-rc.xml &
websockify --web /usr/share/novnc 0.0.0.0:6080 127.0.0.1:5901 &
P="$(command -v pencil || ls /opt/pencil*/pencil 2>/dev/null | head -1)"
# closing Pencil's window starts it again — the studio is always there to reconnect to
( cd /data/Documents; while true; do "$P" --no-sandbox --disable-gpu; sleep 2; done ) &
wait -n
exit 1
SH;

  $rc = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<!-- Alpha Panel · Pencil studio: one desktop, the main window maximised without a title bar; dialogs float. -->
<openbox_config xmlns="http://openbox.org/3.4/rc">
  <focus><focusNew>yes</focusNew><followMouse>no</followMouse></focus>
  <placement><policy>Smart</policy><center>yes</center></placement>
  <desktops><number>1</number></desktops>
  <applications>
    <application class="*" type="normal"><decor>no</decor><maximized>yes</maximized></application>
  </applications>
</openbox_config>
XML;

  $index = <<<'HTML'
<!doctype html>
<meta charset="utf-8">
<meta name="robots" content="noindex, nofollow">
<title>Pencil</title>
<script>location.replace('vnc.html?autoconnect=1&resize=remote&reconnect=1&reconnect_delay=1500&show_dot=1&path=' + encodeURIComponent(location.pathname.replace(/^\//, '').replace(/[^/]*$/, '') + 'websockify'));</script>
HTML;

  return ['Dockerfile' => $docker, 'entrypoint.sh' => $entry . "\n", 'openbox-rc.xml' => $rc . "\n", 'index.html' => $index . "\n"];
}
function image_info() {
  [$o, $rc] = sh('docker image inspect ' . escapeshellarg(IMAGE) . ' --format "{{.Created}}"');
  if ($rc !== 0 || trim($o) === '') return null;
  // the size `docker images` shows (unpacked, on disk); inspect's .Size is the compressed content with the containerd store
  [$sz] = sh('docker images ' . escapeshellarg(IMAGE) . ' --format "{{.Size}}"');
  $mb = 0; if (preg_match('/^([\d.]+)\s*([kKMG]?B)/', trim($sz), $m)) $mb = (int) round((float) $m[1] * ['B' => 1 / 1048576, 'kB' => 1 / 1024, 'KB' => 1 / 1024, 'MB' => 1, 'GB' => 1024][$m[2]]);
  return ['size_mb' => $mb, 'created' => (int) strtotime(trim($o))];
}
function unit_active() { [$o] = sh('systemctl is-active ' . UNIT); return trim($o) === 'active'; }
function connections() {
  // established connections TO the published port = someone is looking at Pencil through nginx
  [$o] = sh('ss -Htn state established "( dport = :' . PORT . ' )"');
  return count(array_filter(explode("\n", trim($o)), 'strlen'));
}
function touch_active() { file_put_contents(STATE . '/last-active', (string) time()); }
function last_active() { return (int) @file_get_contents(STATE . '/last-active'); }

/* ---------- nginx: one proxied location on the admin vhost ---------- */
function admin_vhost() { [$o] = sh('grep -ls "__auth_check" /etc/nginx/sites-enabled/* 2>/dev/null | head -1'); return trim($o); }
function snippet_text() {
  return "# Pencil (Addons/pencil): the prototyping studio's noVNC client + WebSocket, proxied to the container on\n"
       . "# 127.0.0.1:" . PORT . " — ADMIN host only, behind the panel session. Written by /usr/local/sbin/alpha-pencil.\n"
       . "location ^~ " . BASE . " {\n"
       . "    auth_request /__auth_check;\n"
       . "    error_page 401 = @applogin;\n"
       . "    proxy_pass http://127.0.0.1:" . PORT . "/;\n"
       . "    proxy_http_version 1.1;\n"
       . "    proxy_set_header Upgrade \$http_upgrade;\n"
       . "    proxy_set_header Connection \"upgrade\";\n"
       . "    proxy_set_header Host \$host;\n"
       . "    proxy_read_timeout 1d;\n"
       . "    proxy_send_timeout 1d;\n"
       . "    proxy_buffering off;\n"
       . "    add_header Strict-Transport-Security \"max-age=15768000\" always;\n"
       . "    add_header X-Frame-Options SAMEORIGIN always;\n"
       . "    add_header X-Content-Type-Options nosniff always;\n"
       . "    add_header Referrer-Policy same-origin always;\n"
       . "    add_header X-Robots-Tag \"noindex, nofollow\" always;\n"
       . "}\n";
}
function nginx_install() {
  $vh = admin_vhost();
  if ($vh === '') return ['ok' => false, 'msg' => 'could not find the admin vhost (the one with /__auth_check)'];
  if (!is_dir(SNIPDIR)) { @mkdir(SNIPDIR, 0755, true); @chmod(SNIPDIR, 0755); }
  $changed = !is_file(SNIPPET) || file_get_contents(SNIPPET) !== snippet_text();
  if ($changed) { file_put_contents(SNIPPET, snippet_text()); @chmod(SNIPPET, 0644); }
  $conf = file_get_contents($vh); $bak = '';
  if (strpos($conf, 'snippets/alpha-admin.d/') === false) {
    $anchor = "    location / { try_files";
    if (strpos($conf, $anchor) === false) return ['ok' => false, 'msg' => 'the admin vhost has no recognisable catch-all location to anchor the include on'];
    $bak = $vh . '.bak-' . date('Ymd-His'); copy($vh, $bak);
    $conf = preg_replace('/^    location \/ \{ try_files/m', "    include snippets/alpha-admin.d/*.conf;\n    location / { try_files", $conf, 1);
    file_put_contents($vh, $conf); $changed = true;
  }
  [$o, $rc] = sh('nginx -t');
  if ($rc !== 0) { if ($bak !== '') copy($bak, $vh); @unlink(SNIPPET); return ['ok' => false, 'msg' => 'nginx -t failed, change rolled back: ' . $o]; }
  if ($changed) sh('systemctl reload nginx');
  return ['ok' => true, 'changed' => $changed, 'vhost' => $vh];
}
function ensure_tree() {
  foreach ([ROOT, ROOT . '/home', ROOT . '/Documents', ROOT . '/versions'] as $d) if (!is_dir($d)) @mkdir($d, 0750, true);
  sh('chown www-data:www-data ' . escapeshellarg(ROOT) . ' ' . escapeshellarg(ROOT . '/home') . ' ' . escapeshellarg(ROOT . '/Documents') . ' ' . escapeshellarg(ROOT . '/versions'));
}
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)) write_settings(settings());
}

ensure_state();
$cmd = (string) ($_SERVER['argv'][1] ?? '');
switch ($cmd) {
  case 'status': {
    $img = image_info(); $act = unit_active();
    $mem = 0; $started = 0;
    if ($act) {
      [$o] = sh('docker stats --no-stream --format "{{.MemUsage}}" ' . NAME);
      if (preg_match('/^([\d.]+)\s*([KMG]i?B)/', trim($o), $m)) $mem = (int) round((float) $m[1] * ['KiB' => 1 / 1024, 'MiB' => 1, 'GiB' => 1024, 'KB' => 1 / 1000, 'MB' => 1, 'GB' => 1000][$m[2]]);
      [$o] = sh('docker inspect -f "{{.State.StartedAt}}" ' . NAME); $started = (int) strtotime(trim($o));
    }
    $vh = admin_vhost();
    $running = null; foreach (glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: [] as $d) { [$st] = job_state($d); if ($st === 'running') { $m = meta($d); $running = ['id' => $m['id'], 'kind' => $m['kind'], 'started' => $m['started']]; } }
    out(['docker' => bin('docker') !== '', 'installed' => $img !== null, 'image' => $img, 'version' => VERSION, 'unit' => is_file(UNITFILE), 'unit_current' => is_file(UNITFILE) && file_get_contents(UNITFILE) === unit_text(),
         'active' => $act, 'mem_mb' => $mem, 'started' => $started, 'connections' => $act ? connections() : 0, 'last_active' => last_active(),
         'settings' => settings(), 'res_options' => RES_OK, 'port' => PORT, 'base' => BASE,
         'nginx_snippet' => is_file(SNIPPET), 'nginx_included' => $vh !== '' && strpos((string) @file_get_contents($vh), 'snippets/alpha-admin.d/') !== false, 'running' => $running]);
  }
  case 'install': case 'update': {
    if (bin('docker') === '') fail('docker is required');
    ensure_tree();
    foreach (build_files() as $f => $txt) file_put_contents(STATE . '/build/' . $f, $txt);
    $q = 'escapeshellarg';
    $wasActive = unit_active();
    $body = 'set -e; '
      . 'echo "== build ' . IMAGE . ' (Pencil ' . VERSION . ' on debian:bookworm-slim' . ($cmd === 'update' ? ', fresh base and packages' : '') . ')"; '
      // the classic builder: BuildKit needs the separate buildx plugin, which a stock docker.io may not have
      . 'DOCKER_BUILDKIT=0 docker build ' . ($cmd === 'update' ? '--pull --no-cache ' : '--pull ') . '--build-arg ' . $q('PENCIL_URL=' . DEB_URL) . ' -t ' . $q(IMAGE) . ' ' . $q(STATE . '/build') . '; '
      . 'echo "== image: $(docker images ' . $q(IMAGE) . ' --format "{{.Size}}") on disk"; '
      . 'echo "== drop superseded Pencil images"; docker image prune -f --filter label=alpha.addon=pencil >/dev/null || true; '
      . 'echo "== unit + nginx"; /usr/local/sbin/alpha-pencil unit-install; /usr/local/sbin/alpha-pencil nginx; '
      . ($wasActive ? 'echo "== restart on the new image"; systemctl restart ' . UNIT . '; ' : '')
      . 'echo "== done: Pencil ' . VERSION . ' ready"';
    out(['ok' => true, 'job' => job_start($cmd, $body)]);
  }
  case 'preflight': {
    [$free, $root] = docker_root_free_mb();
    $img = image_info();
    $need = $img ? 0 : 2100;               // image 1.6 GB + the base image and build layers while it is put together
    $s = settings();
    out(['ok' => true, 'installed' => $img !== null, 'image' => $img, 'docker_root' => $root, 'disk_free_mb' => $free, 'disk_need_mb' => $need,
         'download_mb' => $img ? 0 : 330,   // Debian base ~30 MB + packages ~210 MB + Pencil .deb 88 MB
         'build_mem_mb' => 700, 'run_mem_mb' => 350, 'run_cap_mb' => (int) $s['mem'], 'build_cores' => 1,
         'last_build_secs' => (int) @file_get_contents(STATE . '/last-build'), 'typical_build_secs' => 240] + sys_load());
  }
  case 'delete-image': {
    [$before] = docker_root_free_mb();
    sh('systemctl stop ' . UNIT);
    [$o, $rc] = sh('docker rmi ' . escapeshellarg(IMAGE));
    if ($rc !== 0 && image_info()) fail('Docker refused to delete the image: ' . trim($o));
    sh('docker image prune -f --filter label=alpha.addon=pencil');
    // the Debian base goes too when nothing else on this server is built on it
    sh('docker rmi debian:bookworm-slim');
    [$after] = docker_root_free_mb();
    out(['ok' => true, 'freed_mb' => max(0, $after - $before)]);
  }
  case 'unit-install': { ensure_unit(); out(['ok' => true]); }
  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)]); }
  case 'job': out(['ok' => true] + job_view(job_dir((string) ($_SERVER['argv'][2] ?? ''))));
  case 'start': case 'restart': {
    if (!image_info()) fail('Pencil is not built yet');
    ensure_tree(); ensure_unit();
    touch_active();
    [$o, $rc] = sh('systemctl ' . $cmd . ' ' . UNIT);
    if ($rc !== 0) fail('systemctl ' . $cmd . ' failed: ' . $o);
    // ready = websockify answers through the published port
    $ok = false; for ($i = 0; $i < 60; $i++) { [$c] = sh('curl -s -o /dev/null -w "%{http_code}" --max-time 2 http://127.0.0.1:' . PORT . '/vnc.html'); if (trim($c) === '200') { $ok = true; break; } usleep(500000); }
    out(['ok' => true, 'ready' => $ok]);
  }
  case 'stop': { sh('systemctl stop ' . UNIT); out(['ok' => true, 'active' => unit_active()]); }
  case 'set': {
    $k = (string) ($_SERVER['argv'][2] ?? ''); $v = (string) ($_SERVER['argv'][3] ?? '');
    $s = settings();
    switch ($k) {
      case 'idle': if (!preg_match('/^\d{1,4}$/', $v) || (int) $v > 1440) fail('idle: minutes 0-1440 (0 = never stop)'); $s['idle'] = (int) $v; break;
      case 'mem':  if (!preg_match('/^\d{3,4}$/', $v) || (int) $v < 768 || (int) $v > 8192) fail('mem: MB 768-8192'); $s['mem'] = (int) $v; break;
      case 'cpus': if (!preg_match('/^\d(\.\d)?$/', $v) || (float) $v < 0.5 || (float) $v > 4) fail('cpus: 0.5-4'); $s['cpus'] = (float) $v; break;
      case 'res':  if (!in_array($v, RES_OK, true)) fail('res: one of ' . implode(', ', RES_OK)); $s['res'] = $v; break;
      default: fail('set idle|mem|cpus|res <value>');
    }
    write_settings($s);
    out(['ok' => true, 'settings' => $s, 'restart_needed' => $k !== 'idle' && unit_active()]);
  }
  case 'idle-check': {
    if (!unit_active()) out(['ok' => true, 'active' => false]);
    if (connections() > 0 || last_active() === 0) { touch_active(); out(['ok' => true, 'active' => true, 'in_use' => true]); }
    $s = settings(); $idle = time() - last_active();
    if ($s['idle'] > 0 && $idle > $s['idle'] * 60) {
      sh('systemctl stop ' . UNIT);
      @file_put_contents(STATE . '/idle-stops.log', date('c') . " stopped after " . round($idle / 60) . " idle min\n", FILE_APPEND);
      out(['ok' => true, 'stopped' => true, 'idle_min' => (int) round($idle / 60)]);
    }
    out(['ok' => true, 'active' => true, 'idle_min' => (int) round($idle / 60)]);
  }
  case 'nginx': { $r = nginx_install(); if (empty($r['ok'])) fail($r['msg']); out($r); }
  case 'remove': {
    sh('systemctl stop ' . UNIT); sh('systemctl disable ' . UNIT);
    sh('docker rmi ' . escapeshellarg(IMAGE));
    sh('docker image prune -f --filter label=alpha.addon=pencil');
    foreach (glob(STATE . '/build/*') ?: [] as $f) @unlink($f);
    @unlink(SNIPPET);
    [$o, $rc] = sh('nginx -t'); if ($rc === 0) sh('systemctl reload nginx');
    out(['ok' => true, 'nginx' => $rc === 0]);
  }
  default: fail('usage: alpha-pencil status|preflight|install|update|delete-image|job <id>|start|stop|restart|set <key> <value>|idle-check|unit-check|unit-text|nginx|remove');
}
