#!/usr/bin/env bash
# alphaagent-env-toolkit.sh - install the AlphaAgent document & media toolkit
# into a Debian/Ubuntu image, from the pinned manifest next to this script
# (toolkit.lock).
#
# The same script builds AlphaAgent's own execution-environment image and a
# customer's custom image, so both end up with the identical, pinned set of
# tools the Studio coder relies on from release 1.0.120: LibreOffice (Office ->
# PDF, XLSX recalculation), poppler (PDF -> PNG previews), pandoc, Graphviz,
# weasyprint/reportlab/python-pptx/python-docx/openpyxl/..., Node + mermaid-cli
# (+ the Chromium it renders with) + pptxgenjs, and the IBM Plex / Raleway
# fonts LibreOffice and weasyprint lay text out with.
#
# Usage (as root, at image build time):
#   alphaagent-env-toolkit.sh                 full install, then writes
#                                             /opt/alphaagent/toolkit.lock
#   alphaagent-env-toolkit.sh --apt-only      only the apt block
#   alphaagent-env-toolkit.sh --pip-only      only the pip block
#   alphaagent-env-toolkit.sh --npm-only      only Node + npm packages (+ Chromium)
#   alphaagent-env-toolkit.sh --fonts-only    only the fonts
#   alphaagent-env-toolkit.sh --no-fonts      full install minus the fonts
#   alphaagent-env-toolkit.sh --check         run the lock's `checks` (offline,
#                                             any user) and print JSON; exit 1
#                                             if any check fails. Human-readable
#                                             report on stderr; the LAST stdout
#                                             line is the compact JSON result.
#   alphaagent-env-toolkit.sh --check --lambda-like
#                                             the release gate: same checks, run
#                                             the way the env Lambda runs them
#                                             (non-root uid, HOME unreadable,
#                                             only /tmp writable, no core dumps).
#                                             Run it from OUTSIDE the image as
#       docker run --rm --user 993:990 --read-only --tmpfs /tmp:size=512m \
#         -e HOME=/home/agent <image> \
#         /opt/alphaagent/toolkit/alphaagent-env-toolkit.sh --check --lambda-like
#                                             Needs nothing that exists only at
#                                             build time (no network, no apt).
#                                             Under CPU emulation (amd64 image on
#                                             an arm64 host) the browser checks
#                                             are reported skipped, as for --check.
#   alphaagent-env-toolkit.sh --lock PATH     use a different lock file
#
# Idempotent: re-running skips what is already installed at the pinned version.
# Requirements on the base image: Debian or Ubuntu with apt, and python3 (the
# lock is JSON and the downloads are done with urllib, so curl is NOT required
# and is not installed). Network is needed for the install steps only;
# --check never touches the network.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCK="${SCRIPT_DIR}/toolkit.lock"
INSTALLED_LOCK="/opt/alphaagent/toolkit.lock"

DO_APT=1; DO_PIP=1; DO_NPM=1; DO_FONTS=1; DO_CHECK=0; LAMBDA_LIKE=0; ONLY=0

while [[ $# -gt 0 ]]; do
  case "$1" in
    --apt-only)   DO_APT=1; DO_PIP=0; DO_NPM=0; DO_FONTS=0; ONLY=1 ;;
    --pip-only)   DO_APT=0; DO_PIP=1; DO_NPM=0; DO_FONTS=0; ONLY=1 ;;
    --npm-only)   DO_APT=0; DO_PIP=0; DO_NPM=1; DO_FONTS=0; ONLY=1 ;;
    --fonts-only) DO_APT=0; DO_PIP=0; DO_NPM=0; DO_FONTS=1; ONLY=1 ;;
    --no-fonts)   DO_FONTS=0 ;;
    --check)      DO_CHECK=1 ;;
    --lambda-like) DO_CHECK=1; LAMBDA_LIKE=1 ;;
    --lock)       LOCK="$2"; shift ;;
    -h|--help)    sed -n '2,49p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
    *) echo "unknown flag: $1" >&2; exit 2 ;;
  esac
  shift
done

log() { printf '[alphaagent-toolkit] %s\n' "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }

command -v python3 >/dev/null 2>&1 || die "python3 is required (the lock is JSON and downloads use urllib)"
[[ -f "$LOCK" ]] || die "lock file not found: $LOCK"

# Read a value out of the lock. `lock KEY` prints a scalar; `lock_list KEY`
# prints one array element per line.
lock()      { python3 -c 'import json,sys; v=json.load(open(sys.argv[1])); [v:=v[k] for k in sys.argv[2].split(".")]; print(v if not isinstance(v,(dict,list)) else json.dumps(v))' "$LOCK" "$1"; }
lock_list() { python3 -c 'import json,sys; v=json.load(open(sys.argv[1])); [v:=v[k] for k in sys.argv[2].split(".")]; print("\n".join(v))' "$LOCK" "$1"; }

# --------------------------------------------------------------------------
# --check: run the lock's checks and print JSON. Semantics (also stated in the
# lock's `check_semantics`): each cmd runs under /bin/sh -c; pass = exit 0 AND
# re.search(expect, stdout+stderr). Runs offline, as whoever invokes it - the
# Dockerfile runs it as the runtime user so a root-only success cannot hide a
# permission problem.
#
# Every check runs the way the env Lambda handler runs every exec: RLIMIT_CORE
# soft=0 (a crashing Chrome wrote ~16 x 82 MB core dumps per attempt into
# Lambda's 512 MB /tmp) and, when $HOME is not readable AND writable (Lambda
# runs as uid 993 while /home/agent is drwx------ uid 1000), HOME /
# XDG_CACHE_HOME / TMPDIR redirected under /tmp.
#
# --lambda-like adds the `lambda_like` report block (uid/gid, HOME readability,
# /tmp writability + usage, core dumps left behind, whether the invocation was a
# faithful Lambda emulation) and, if the caller's HOME IS writable (a dev running
# it as the image user), emulates Lambda's unreadable HOME by pointing HOME at a
# path that does not exist. Faithful = non-root, uid != 1000, HOME unreadable,
# /tmp writable - what the documented `docker run --user 993:990 --read-only
# --tmpfs /tmp ... -e HOME=/home/agent` gate provides.
#
# Output contract: human-readable pretty JSON + warnings on STDERR; the LAST
# line on STDOUT is the compact one-line JSON, so a gate can `tail -n1 | jq`.
# --------------------------------------------------------------------------
run_checks() {
  python3 - "$1" "$2" <<'PY'
import glob, json, os, platform, re, resource, shutil, subprocess, sys
lock_path, lambda_like = sys.argv[1], sys.argv[2] == "1"
lock = json.load(open(lock_path))
HARNESS_TIMEOUT_S = 180  # generous ceiling for a cold soffice/Chrome under emulation; the Studio probe uses the lock's check_timeout_s/browser_check_timeout_s

def _emulated() -> bool:
    """x86_64 userland on a non-x86 host (QEMU user-mode, e.g. Docker Desktop
    on an arm64 Mac): /proc/cpuinfo is the host's and has no x86 `flags:` line.
    Chromium aborts under that emulation, so browser checks are *skipped*
    there - reported as such, never as passed. Native CI/Lambda: never true."""
    try:
        cpuinfo = open("/proc/cpuinfo").read()
    except OSError:
        return False
    return platform.machine() == "x86_64" and not re.search(r"^flags\s*:", cpuinfo, re.M)

def _usable_home(path: str) -> bool:
    return bool(path) and os.access(path, os.R_OK | os.W_OK | os.X_OK)

def _no_core_dumps() -> None:
    """preexec_fn: RLIMIT_CORE soft=0 (lowering the soft limit is always allowed)."""
    _soft, hard = resource.getrlimit(resource.RLIMIT_CORE)
    resource.setrlimit(resource.RLIMIT_CORE, (0, hard))

tmp = os.environ.get("TMPDIR") or "/tmp"
if not os.access(tmp, os.W_OK):
    tmp = "/tmp"
home = os.environ.get("HOME", "")
info = {
    "uid": os.getuid(), "gid": os.getgid(), "home": home,
    "home_readable": bool(home) and os.access(home, os.R_OK | os.X_OK),
    "home_writable": bool(home) and os.access(home, os.W_OK),
    "tmp": tmp, "tmp_writable": os.access(tmp, os.W_OK),
}
env = dict(os.environ)
if lambda_like and info["home_writable"]:
    home = "/nonexistent/alphaagent-lambda-like-home"
    env["HOME"] = home
    info["emulated_home"] = home
info["faithful"] = bool(lambda_like and info["uid"] not in (0, 1000)
                        and not info["home_writable"] and info["tmp_writable"])
if not _usable_home(env.get("HOME", "")):
    fallback_home = os.path.join(tmp, "home")
    os.makedirs(os.path.join(fallback_home, ".cache"), exist_ok=True)
    env["HOME"] = fallback_home
    env["XDG_CACHE_HOME"] = os.path.join(fallback_home, ".cache")
    env["TMPDIR"] = tmp
info["exec_home"] = env["HOME"]
info["rlimit_core_soft"] = 0
cores_before = set(glob.glob(os.path.join(tmp, "core.*")))
usage = shutil.disk_usage(tmp)
info["tmp_total_bytes"], info["tmp_used_bytes"] = usage.total, usage.used

emulated = _emulated()
if emulated and os.environ.get("AA_TOOLKIT_FORCE_BROWSER_CHECKS") == "1":
    # The 1.1.0 flag set (--no-zygote --single-process) turned out to run under
    # Docker Desktop's emulation on an arm64 Mac (both render checks passed
    # there as uid 993, ~15 s mermaid / ~50 s kaleido). Opt in to run them
    # anyway; a pass here is still not a substitute for native amd64.
    emulated = False
    print("[alphaagent-toolkit] AA_TOOLKIT_FORCE_BROWSER_CHECKS=1: running browser checks under CPU emulation", file=sys.stderr)
results, ok_all, skipped = [], True, 0
for c in lock.get("checks", []):
    if c.get("needs_browser") and emulated:
        skipped += 1
        results.append({"name": c["name"], "ok": None, "skipped": True,
                        "reason": "needs a Chromium launch; x86_64 under CPU emulation cannot run it - verify on native amd64"})
        continue
    try:
        p = subprocess.run(["/bin/sh", "-c", c["cmd"]], capture_output=True, text=True,
                           timeout=HARNESS_TIMEOUT_S, env=env, preexec_fn=_no_core_dumps)
        out = (p.stdout or "") + (p.stderr or "")
        ok = p.returncode == 0 and re.search(c["expect"], out, re.M) is not None
        detail = out.strip().splitlines()[0][:200] if out.strip() else ""
        if not ok and out.strip():
            detail = out.strip().splitlines()[-1][-200:]
        rc = p.returncode
    except subprocess.TimeoutExpired:
        ok, detail, rc = False, f"timeout after {HARNESS_TIMEOUT_S}s", None
    ok_all &= ok
    results.append({"name": c["name"], "ok": ok, "exit": rc, "output": detail})

cores_after = sorted(set(glob.glob(os.path.join(tmp, "core.*"))) - cores_before)
info["core_dumps_left"] = len(cores_after)
if lambda_like:
    # A check that crashed Chrome and left a core dump would fill Lambda's /tmp.
    results.append({"name": "no-core-dumps", "ok": not cores_after, "exit": 0,
                    "output": "no core dumps written under " + tmp if not cores_after
                    else "core dumps left: " + ", ".join(cores_after[:5])})
    ok_all &= not cores_after
usage = shutil.disk_usage(tmp)
info["tmp_used_bytes_after"] = usage.used

report = {"toolkit_version": lock.get("toolkit_version"),
          "studio_min_release": lock.get("studio_min_release"),
          "ok": ok_all, "emulated": emulated, "skipped": skipped,
          "lambda_like": info if lambda_like else None,
          "checks": results}
print(json.dumps(report, indent=2), file=sys.stderr)
if skipped:
    print(f"[alphaagent-toolkit] WARNING: {skipped} browser check(s) skipped under CPU emulation; "
          "they must pass on native amd64 before this image ships", file=sys.stderr)
if lambda_like and not info["faithful"]:
    print("[alphaagent-toolkit] WARNING: --lambda-like ran as uid "
          f"{info['uid']} with HOME writable={info['home_writable']}: not a faithful Lambda "
          "(use the documented docker run --user 993:990 --read-only --tmpfs /tmp gate)", file=sys.stderr)
sys.stdout.flush()
print(json.dumps(report, separators=(",", ":")))
sys.exit(0 if ok_all else 1)
PY
}

if [[ "$DO_CHECK" == "1" ]]; then
  # Prefer the lock baked into the image (what the Studio probe reads); fall
  # back to the one next to the script when --check runs before the install
  # finished (e.g. a partial customer image).
  CHECK_LOCK="$LOCK"
  [[ -f "$INSTALLED_LOCK" && "$LOCK" == "${SCRIPT_DIR}/toolkit.lock" ]] && CHECK_LOCK="$INSTALLED_LOCK"
  run_checks "$CHECK_LOCK" "$LAMBDA_LIKE"
  exit $?
fi

[[ "$(id -u)" == "0" ]] || die "install steps must run as root (use --check for a non-root verification)"

ARCH="$(uname -m)"
export DEBIAN_FRONTEND=noninteractive

# download URL DEST SHA256 - urllib + sha256 verification, no curl needed.
download() {
  python3 - "$1" "$2" "$3" <<'PY'
import hashlib, os, sys, urllib.request
url, dest, want = sys.argv[1:4]
if os.path.exists(dest) and hashlib.sha256(open(dest, "rb").read()).hexdigest() == want:
    sys.exit(0)
tmp = dest + ".part"
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "alphaagent-env-toolkit"}), timeout=120) as r, open(tmp, "wb") as f:
    while True:
        chunk = r.read(1 << 20)
        if not chunk:
            break
        f.write(chunk)
got = hashlib.sha256(open(tmp, "rb").read()).hexdigest()
if got != want:
    os.unlink(tmp)
    sys.exit(f"sha256 mismatch for {url}: expected {want}, got {got}")
os.replace(tmp, dest)
PY
}

# --------------------------------------------------------------------------
# apt
# --------------------------------------------------------------------------
if [[ "$DO_APT" == "1" ]]; then
  command -v apt-get >/dev/null 2>&1 || die "apt-get not found: the toolkit supports Debian/Ubuntu bases"
  log "apt: resolving package names"
  apt-get update -qq
  PKGS=()
  while IFS= read -r spec; do
    [[ -z "$spec" ]] && continue
    # "a|b" means: install the first alternative this release ships (t64
    # renames between Debian 12/13 and Ubuntu 22.04/24.04).
    chosen=""
    IFS='|' read -r -a alts <<<"$spec"
    for alt in "${alts[@]}"; do
      if [[ -n "$(apt-cache policy "$alt" 2>/dev/null | sed -n 's/^  Candidate: //p' | grep -v '^(none)$')" ]]; then
        chosen="$alt"; break
      fi
    done
    [[ -n "$chosen" ]] || die "apt: none of '$spec' is installable on this base"
    PKGS+=("$chosen")
  done < <(lock_list apt)
  log "apt: installing ${#PKGS[@]} packages"
  apt-get install -y --no-install-recommends "${PKGS[@]}"
  rm -rf /var/lib/apt/lists/*
fi

# --------------------------------------------------------------------------
# pip
# --------------------------------------------------------------------------
if [[ "$DO_PIP" == "1" ]]; then
  if ! python3 -m pip --version >/dev/null 2>&1; then
    log "pip: python3 -m pip missing, installing python3-pip"
    apt-get update -qq && apt-get install -y --no-install-recommends python3-pip && rm -rf /var/lib/apt/lists/*
  fi
  mapfile -t PIPS < <(lock_list pip)
  log "pip: installing ${#PIPS[@]} pinned packages"
  # PIP_BREAK_SYSTEM_PACKAGES: a no-op on the python:* images, required on a
  # distro python (PEP 668) - the image IS the environment here.
  PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install --no-cache-dir "${PIPS[@]}"
fi

# --------------------------------------------------------------------------
# Node + npm packages + Chromium (via puppeteer)
# --------------------------------------------------------------------------
if [[ "$DO_NPM" == "1" ]]; then
  NODE_VERSION="$(lock node.version)"
  NODE_PREFIX="$(lock node.install_prefix)"
  if [[ "$(command -v node >/dev/null 2>&1 && node -v || true)" == "v${NODE_VERSION}" ]]; then
    log "node: v${NODE_VERSION} already installed"
  else
    NODE_URL="$(lock "node.tarballs.${ARCH}.url")" || die "node: no tarball pinned for arch ${ARCH}"
    NODE_SHA="$(lock "node.tarballs.${ARCH}.sha256")"
    log "node: installing v${NODE_VERSION} (${ARCH}) into ${NODE_PREFIX}"
    TARBALL="/tmp/node-v${NODE_VERSION}-${ARCH}.tar.gz"
    download "$NODE_URL" "$TARBALL" "$NODE_SHA"
    python3 - "$TARBALL" "$NODE_PREFIX" <<'PY'
import os, sys, tarfile
tarball, prefix = sys.argv[1:3]
with tarfile.open(tarball) as t:
    for m in t.getmembers():
        parts = m.name.split("/", 1)
        if len(parts) < 2 or parts[1] in ("", "LICENSE", "README.md", "CHANGELOG.md"):
            continue
        m.name = parts[1]
        if m.islnk() and m.linkname:
            m.linkname = m.linkname.split("/", 1)[1] if "/" in m.linkname else m.linkname
        t.extract(m, prefix)
PY
    rm -f "$TARBALL"
    [[ "$(node -v)" == "v${NODE_VERSION}" ]] || die "node: install did not produce v${NODE_VERSION} on PATH"
  fi

  mapfile -t NPMS < <(lock_list npm)
  CHROME_CACHE="$(lock chromium.cache_dir)"
  CHROME_LINK="$(lock chromium.executable_link)"
  PUPPETEER_CFG="$(lock chromium.puppeteer_config)"
  export PUPPETEER_CACHE_DIR="$CHROME_CACHE"
  # mmdc is run with headless:true (see the wrapper below), so the extra
  # chrome-headless-shell download (~200 MB unpacked) is never used; skip it.
  export PUPPETEER_SKIP_CHROME_HEADLESS_SHELL_DOWNLOAD=true
  if [[ "$ARCH" != "x86_64" ]]; then
    # Chrome for Testing has no linux/arm64 build. Install the packages so the
    # toolchain is complete, but mermaid/kaleido rendering will fail --check
    # here - honestly. Production images are linux/amd64.
    log "npm: ${ARCH} has no Chrome for Testing build; skipping the Chromium download (mermaid/kaleido checks will fail on this arch)"
    export PUPPETEER_SKIP_DOWNLOAD=true
  fi
  mkdir -p "$CHROME_CACHE"
  # `unzip` is in the apt list because @puppeteer/browsers shells out to it to
  # extract chrome-linux64.zip; without it the postinstall fails with
  # "no zip archiver is available" - visible only with --foreground-scripts.
  command -v unzip >/dev/null 2>&1 || die "npm: unzip is required to unpack Chromium (run the apt step first, or install unzip)"
  log "npm: installing ${NPMS[*]}"
  npm install -g --no-fund --no-audit --foreground-scripts --loglevel=error "${NPMS[@]}"
  npm cache clean --force >/dev/null 2>&1 || true

  mkdir -p /opt/alphaagent
  CHROME_BIN="$(find "$CHROME_CACHE" -type f -name chrome -path '*chrome-linux64*' 2>/dev/null | head -n1 || true)"
  if [[ -z "$CHROME_BIN" && "$ARCH" == "x86_64" ]]; then
    die "npm: puppeteer did not leave a Chromium under ${CHROME_CACHE} - mermaid and kaleido rendering would be broken; see the npm output above"
  fi
  KALEIDO_WRAPPER="$(lock chromium.kaleido_wrapper)"
  if [[ -n "$CHROME_BIN" ]]; then
    # Stable paths that do not embed the Chrome build number: puppeteer and
    # mmdc use PUPPETEER_EXECUTABLE_PATH (the raw binary; puppeteer passes the
    # lock's args itself via the config below). kaleido/choreographer get the
    # WRAPPER: choreographer has no way to pass extra Chromium flags (its
    # Chromium() rejects unknown kwargs and plotly's write_image forwards
    # nothing browser-related), so the Lambda-safe flags from
    # chromium.kaleido_args are appended by a launcher script that execs the
    # real binary. exec keeps fds 3/4 (choreographer's DevTools pipe) intact.
    ln -sfn "$CHROME_BIN" "$CHROME_LINK"
    # Lambda runs the handler as a non-root runtime user (uid 993 in practice,
    # 1000 in the image): the cache must be world-readable and the binaries
    # world-executable.
    chmod -R a+rX "$CHROME_CACHE"
    log "npm: Chromium at ${CHROME_BIN} -> ${CHROME_LINK}"
  fi
  # The kaleido launcher is written on every arch (a customer arm64 dev build
  # then fails the kaleido-render check honestly at launch, not at lookup).
  python3 - "$LOCK" "$KALEIDO_WRAPPER" "$CHROME_LINK" <<'PY'
import json, os, shlex, sys
lock, wrapper, chrome = sys.argv[1:4]
ch = json.load(open(lock))["chromium"]
args = ch.get("kaleido_args") or ch["args"]
os.makedirs(os.path.dirname(wrapper), exist_ok=True)
with open(wrapper, "w") as f:
    f.write("#!/bin/sh\n"
            "# AlphaAgent toolkit: Chrome launcher for kaleido/choreographer (written by\n"
            "# alphaagent-env-toolkit.sh from toolkit.lock chromium.kaleido_args).\n"
            "# choreographer cannot be given extra Chromium flags, so the Lambda-safe set\n"
            "# is appended here; exec preserves the DevTools pipe fds (3, 4) it passes.\n"
            "# A crashing Chrome must never fill Lambda's 512 MB /tmp with core dumps.\n"
            "ulimit -S -c 0 2>/dev/null || true\n"
            f"exec {shlex.quote(chrome)} \"$@\" {' '.join(shlex.quote(a) for a in args)}\n")
os.chmod(wrapper, 0o755)
PY
  ln -sfn "$KALEIDO_WRAPPER" /usr/local/bin/chrome
  # choreographer's lookup order is: its own download path under HOME
  # (~/.local/share/choreographer/deps/chrome-linux64/chrome - Path.exists()
  # RAISES PermissionError when HOME is unreadable, which is why kaleido
  # died in Lambda before ever reading BROWSER_PATH; the runtime fixes that
  # by giving every exec a readable HOME), then its legacy in-package path
  # <site-packages>/choreographer/cli/browser_exe/chrome-linux64/chrome, then
  # BROWSER_PATH, then `chrome` on PATH. Point the in-package path at the
  # wrapper so resolution is independent of env (Lambda never sources
  # /etc/profile.d; a customer Dockerfile may forget ENV BROWSER_PATH).
  CHOREO_DIR="$(python3 -c 'import choreographer, os; print(os.path.dirname(choreographer.__file__))' 2>/dev/null || true)"
  if [[ -n "$CHOREO_DIR" ]]; then
    mkdir -p "${CHOREO_DIR}/cli/browser_exe/chrome-linux64"
    ln -sfn "$KALEIDO_WRAPPER" "${CHOREO_DIR}/cli/browser_exe/chrome-linux64/chrome"
    chmod -R a+rX "${CHOREO_DIR}/cli/browser_exe"
    log "npm: choreographer legacy browser path -> ${KALEIDO_WRAPPER}"
  else
    log "npm: choreographer not importable yet (pip step not run?); kaleido will rely on BROWSER_PATH=${KALEIDO_WRAPPER}"
  fi

  # Puppeteer launch options mmdc needs in a Lambda/Firecracker container: the
  # lock's chromium.args (no user-namespace sandbox, no zygote, single process,
  # no GPU, no /dev/shm - the 1.0.0 set without --no-zygote/--single-process
  # crashed Chrome in ~1 s on the real env Lambda and left ~16 core dumps per
  # attempt), and headless:true so the shared full-Chrome binary is used
  # (mmdc's default is headless:"shell", which would need the separate
  # chrome-headless-shell). --user-data-dir is added per run by the wrapper.
  python3 - "$LOCK" "$PUPPETEER_CFG" "$CHROME_LINK" <<'PY'
import json, os, sys
lock, cfg_path, chrome = sys.argv[1:4]
args = json.load(open(lock))["chromium"]["args"]
cfg = {"headless": True, "args": args}
if os.path.exists(chrome):
    cfg["executablePath"] = chrome
os.makedirs(os.path.dirname(cfg_path), exist_ok=True)
json.dump(cfg, open(cfg_path, "w"), indent=2)
PY
  chmod a+r "$PUPPETEER_CFG"

  # Wrap mmdc so the coder never has to know about the puppeteer config: the
  # wrapper injects `-p` unless the caller passed one, and pins the cache /
  # executable paths so no ENV lines are needed for mermaid to work.
  # Resolve the real CLI from the global module root, NOT via readlink of
  # whatever `mmdc` is on PATH: on a first run that is npm's symlink into
  # src/cli.js (and `cat >` through the symlink would overwrite cli.js
  # itself - the first build did exactly that); on a re-run it is this
  # wrapper. Remove the symlink before writing so the wrapper is a real file.
  MMDC_REAL="$(npm root -g)/@mermaid-js/mermaid-cli/src/cli.js"
  [[ -f "$MMDC_REAL" ]] || die "npm: mmdc CLI not found at ${MMDC_REAL}"
  head -c 2 "$MMDC_REAL" | grep -q '#!' || die "npm: ${MMDC_REAL} does not look like the mermaid CLI (was it overwritten?)"
  rm -f "${NODE_PREFIX}/bin/mmdc"
  cat > "${NODE_PREFIX}/bin/mmdc" <<'EOF'
#!/bin/sh
# AlphaAgent toolkit wrapper for mermaid-cli (installed by alphaagent-env-toolkit.sh).
export PUPPETEER_CACHE_DIR="${PUPPETEER_CACHE_DIR:-@CHROME_CACHE@}"
[ -e "@CHROME_LINK@" ] && export PUPPETEER_EXECUTABLE_PATH="${PUPPETEER_EXECUTABLE_PATH:-@CHROME_LINK@}"
# Chromium, puppeteer and fontconfig need a HOME they can read AND write. In
# Lambda the image's HOME (/home/agent, drwx------ uid 1000) is neither for the
# runtime uid (993): -r, -w and -x are all false, so redirect under TMPDIR,
# the only writable place. A minimal image may have no HOME at all.
export TMPDIR="${TMPDIR:-/tmp}"
[ -d "$TMPDIR" ] && [ -w "$TMPDIR" ] || export TMPDIR=/tmp
if [ -z "$HOME" ] || [ ! -d "$HOME" ] || [ ! -r "$HOME" ] || [ ! -w "$HOME" ] || [ ! -x "$HOME" ]; then
  export HOME="$TMPDIR/home"
  export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
  case "$XDG_CACHE_HOME" in "$HOME"/*) ;; *) export XDG_CACHE_HOME="$HOME/.cache" ;; esac
  mkdir -p "$XDG_CACHE_HOME" 2>/dev/null || true
fi
# A crashing Chrome must never fill Lambda's 512 MB /tmp with ~82 MB core dumps
# (the handler also sets RLIMIT_CORE=0 for every exec; this is belt and braces).
ulimit -S -c 0 2>/dev/null || true
# A caller with its own puppeteer config gets it verbatim.
for a in "$@"; do
  case "$a" in -p|--puppeteerConfigFile) exec node "@MMDC_REAL@" "$@" ;; esac
done
# Fresh Chrome profile per run under TMPDIR: --user-data-dir is deliberately
# NOT in the lock (a shared profile dir is exactly what breaks concurrent or
# warm-instance runs), so build a per-run puppeteer config carrying it, run,
# and always remove the profile so a warm Lambda's /tmp does not fill up.
udd="$(mktemp -d "$TMPDIR/aa-mmdc.XXXXXX" 2>/dev/null)" || exec node "@MMDC_REAL@" -p "@PUPPETEER_CFG@" "$@"
python3 - "@PUPPETEER_CFG@" "$udd" <<'PY' || exec node "@MMDC_REAL@" -p "@PUPPETEER_CFG@" "$@"
import json, os, sys
cfg_path, udd = sys.argv[1:3]
cfg = json.load(open(cfg_path))
cfg["args"] = [a for a in cfg.get("args", []) if not a.startswith("--user-data-dir")]
cfg["args"].append("--user-data-dir=" + os.path.join(udd, "profile"))
json.dump(cfg, open(os.path.join(udd, "puppeteer-config.json"), "w"))
PY
node "@MMDC_REAL@" -p "$udd/puppeteer-config.json" "$@"
rc=$?
rm -rf "$udd"
exit $rc
EOF
  sed -i -e "s|@CHROME_CACHE@|${CHROME_CACHE}|g" -e "s|@CHROME_LINK@|${CHROME_LINK}|g" \
         -e "s|@MMDC_REAL@|${MMDC_REAL}|g" -e "s|@PUPPETEER_CFG@|${PUPPETEER_CFG}|g" "${NODE_PREFIX}/bin/mmdc"
  chmod 755 "${NODE_PREFIX}/bin/mmdc"

  # Global node modules must be require()-able from a script anywhere in the
  # workspace (`require('pptxgenjs')`). NODE_PATH is exported by the image's
  # ENV; this profile.d entry covers interactive shells too.
  NODE_MODULES_GLOBAL="$(npm root -g)"
  mkdir -p /etc/profile.d
  cat > /etc/profile.d/alphaagent-toolkit.sh <<EOF
export NODE_PATH="\${NODE_PATH:-${NODE_MODULES_GLOBAL}}"
export PUPPETEER_CACHE_DIR="\${PUPPETEER_CACHE_DIR:-${CHROME_CACHE}}"
export BROWSER_PATH="\${BROWSER_PATH:-${KALEIDO_WRAPPER}}"
EOF
fi

# --------------------------------------------------------------------------
# Fonts (OFL; TTF; pinned URL + sha256)
# --------------------------------------------------------------------------
if [[ "$DO_FONTS" == "1" ]]; then
  FONTS_DIR="$(lock fonts_dir)"
  mkdir -p "$FONTS_DIR"
  log "fonts: installing into ${FONTS_DIR}"
  python3 - "$LOCK" "$FONTS_DIR" <<'PY'
import hashlib, json, os, sys, urllib.request, zipfile
lock, fonts_dir = sys.argv[1:3]
for f in json.load(open(lock))["fonts"]:
    if "extract" in f:
        targets = {m: os.path.join(fonts_dir, os.path.basename(m)) for m in f["extract"]}
    else:
        targets = {None: os.path.join(fonts_dir, f["file"])}
    if all(os.path.exists(p) for p in targets.values()) and "extract" in f:
        continue  # zip members: presence is the idempotency signal
    if None in targets and os.path.exists(targets[None]) and \
            hashlib.sha256(open(targets[None], "rb").read()).hexdigest() == f["sha256"]:
        continue
    req = urllib.request.Request(f["url"], headers={"User-Agent": "alphaagent-env-toolkit"})
    with urllib.request.urlopen(req, timeout=120) as r:
        data = r.read()
    got = hashlib.sha256(data).hexdigest()
    if got != f["sha256"]:
        sys.exit(f"sha256 mismatch for {f['url']}: expected {f['sha256']}, got {got}")
    if "extract" in f:
        tmp = os.path.join(fonts_dir, ".dl.zip")
        open(tmp, "wb").write(data)
        with zipfile.ZipFile(tmp) as z:
            for member, dest in targets.items():
                open(dest, "wb").write(z.read(member))
        os.unlink(tmp)
    else:
        open(targets[None], "wb").write(data)
    print(f"[alphaagent-toolkit] fonts: {f['family']} <- {os.path.basename(f['url'])}", file=sys.stderr)
PY
  chmod -R a+rX "$FONTS_DIR"
  fc-cache -f >/dev/null
fi

# --------------------------------------------------------------------------
# Record what was installed - the Studio probe reads this file.
# --------------------------------------------------------------------------
if [[ "$ONLY" == "0" ]]; then
  mkdir -p "$(dirname "$INSTALLED_LOCK")"
  cp "$LOCK" "$INSTALLED_LOCK"
  chmod a+r "$INSTALLED_LOCK"
  log "installed toolkit $(lock toolkit_version); lock recorded at ${INSTALLED_LOCK}"
  [[ "$DO_FONTS" == "1" ]] || log "note: --no-fonts was given; the fonts-* checks will fail until fonts are installed"
else
  log "partial install done (a --*-only flag was given); ${INSTALLED_LOCK} is written only by a full run"
fi
