#!/usr/bin/env bash
# VNC + browser lifecycle — single source of truth.
# Called by systemd ExecStartPre (boot) and lib/vnc.ts ensureVnc() (recovery).
#
# Usage: vnc.sh start | stop | start-chrome | start-chrome-native | cdp-liveness | status
#
# Components (all four device-singletons brand-scoped; Tasks 553 + 924):
#   Xtigervnc :${VNC_DISPLAY}      — virtual X11 display per brand
#   Xtigervnc -rfbport ${RFB_PORT} — RFB port per brand
#   websockify :${WEBSOCKIFY_PORT} — WebSocket bridge per brand
#   Chromium                       — headed browser with --user-data-dir per brand
#   CDP :${CDP_PORT}               — browser plugin (browser-render) CDP endpoint per brand
#
# Brand isolation (Tasks 553 + 924): the X display number, Chromium profile,
# rfbport, websockify port, and CDP port are all brand-scoped so any number
# of brands on the same device run their full VNC stacks concurrently
# without sharing session cookies, extensions, local storage, or display
# binding. Defaults: Maxy=:99/5900/6080/9222, Real Agent=:100/5901/6081/
# 9223, Maxy-2=:101/5902/6082/9224, Maxy-3=:102/5903/6083/9225,
# Maxy-4=:103/5904/6084/9226. Each brand's port set is stamped in
# brand.json at install time and re-read at runtime; missing fields are
# derived from `vncDisplay` via the ordinal offset rule.
#
# retired the admin-UI terminal surface entirely. This script
# no longer spawns GUI terminal emulators of any kind; upgrades and
# cloudflare-setup actions run via the detached action runner
# (/api/admin/actions/*) rather than an in-browser terminal.
#
# Display modes (DISPLAY_MODE env var, set by installer --display flag):
#   virtual (default) — Chromium runs on the brand's :${VNC_DISPLAY}
#   native            — Chromium runs on the login session's real display
#                        (discovered via loginctl, NOT from $DISPLAY which
#                         is poisoned by the systemd Environment=DISPLAY line)

set -uo pipefail

# Derive config dir AND VNC display from brand.json so logs and X display go
# to the correct brand-specific values (e.g. ~/.realagent/ + :100 instead of
# ~/.maxy/ + :99). Primary source is the script's own filesystem location;
# $MAXY_PLATFORM_ROOT is a fallback.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLATFORM_ROOT="${MAXY_PLATFORM_ROOT:-$(dirname "$SCRIPT_DIR")}"
BRAND_JSON="${PLATFORM_ROOT}/config/brand.json"

# brand.json is the single source of truth at runtime; missing
# fields loud-fail rather than silently substituting a vncDisplay-derived
# offset (silent-fallback-masks-root-cause recurrence). The install-time
# offset rule in packages/create-maxy-code/src/index.ts stamps every brand at
# build time, so a correctly-installed device always has all five fields.
if [ ! -f "$BRAND_JSON" ]; then
  echo "[vnc.sh] error reason=brand-config-missing path=$BRAND_JSON" >&2
  exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
  echo "[vnc.sh] error reason=brand-config-missing path=$BRAND_JSON detail=\"jq not on PATH\"" >&2
  exit 1
fi

# `exit 1` inside a `$(…)` command substitution only kills the subshell, so
# the loud-fail uses a parent-context `||` chain instead. Each field is read
# with `jq -e` (exit non-zero on null/missing) and a parent-context error
# helper invokes the script-level `exit 1`.
_fail_brand_field() {
  local field="$1"
  local keys
  keys=$(jq -r 'keys | join(",")' "$BRAND_JSON" 2>/dev/null || echo "unparseable")
  local brand_label
  brand_label=$(jq -r '.configDir // "unknown"' "$BRAND_JSON" 2>/dev/null | sed 's/^\.//')
  echo "[vnc.sh] error reason=cdp-port-unresolved brand=$brand_label path=$BRAND_JSON field=$field json_keys=$keys" >&2
  exit 1
}

CONFIG_DIR=$(jq -er '.configDir' "$BRAND_JSON" 2>/dev/null) || _fail_brand_field configDir
VNC_DISPLAY_NUM=$(jq -er '.vncDisplay' "$BRAND_JSON" 2>/dev/null) || _fail_brand_field vncDisplay
RFB_PORT=$(jq -er '.rfbPort' "$BRAND_JSON" 2>/dev/null) || _fail_brand_field rfbPort
WEBSOCKIFY_PORT=$(jq -er '.websockifyPort' "$BRAND_JSON" 2>/dev/null) || _fail_brand_field websockifyPort
CDP_PORT=$(jq -er '.cdpPort' "$BRAND_JSON" 2>/dev/null) || _fail_brand_field cdpPort
BRAND_HOSTNAME=$(jq -r '.hostname // empty' "$BRAND_JSON" 2>/dev/null)
[ -z "$BRAND_HOSTNAME" ] && BRAND_HOSTNAME="${CONFIG_DIR#.}"

VNC_DISPLAY=":${VNC_DISPLAY_NUM}"
MAXY_DIR="${HOME}/${CONFIG_DIR}"
LOG_DIR="${MAXY_DIR}/logs"
LOG_FILE="${LOG_DIR}/vnc-boot.log"
CHROMIUM_PROFILE_DIR="${MAXY_DIR}/chromium-profile"

mkdir -p "$LOG_DIR"

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"; }

# resolve the absolute Chromium binary path from the install-time
# config file. The installer (packages/create-maxy-code/src/index.ts
# ensureNonSnapChromium + writeChromiumBinaryPathFile) writes this file with
# the non-snap binary chosen for this device — `/usr/bin/chromium` on Pi
# Bookworm (real .deb) or `/usr/bin/google-chrome-stable` on Ubuntu Noble
# laptop where the system `/usr/bin/chromium` realpaths to the snap launcher.
# Hard-fail when the file is absent or the resolved path is snap-confined:
# the AppArmor `home` profile denies writes to ~/.{brand}/chromium-profile/
# and Chromium will never start the CDP listener. Loud fail beats silent
# fallback (silent-fallback-masks-root-cause).
CHROMIUM_BIN_FILE="${PLATFORM_ROOT}/config/chromium-binary.path"
if [ ! -r "$CHROMIUM_BIN_FILE" ]; then
  log "[vnc.sh:chromium] FATAL: ${CHROMIUM_BIN_FILE} missing or unreadable"
  echo "ERROR: ${CHROMIUM_BIN_FILE} missing or unreadable." >&2
  echo "       Re-run the installer to provision a non-snap Chromium." >&2
  exit 1
fi
CHROMIUM_BIN="$(head -n1 "$CHROMIUM_BIN_FILE" | tr -d '[:space:]')"
if [ -z "$CHROMIUM_BIN" ]; then
  log "[vnc.sh:chromium] FATAL: ${CHROMIUM_BIN_FILE} is empty"
  echo "ERROR: ${CHROMIUM_BIN_FILE} is empty — re-run installer." >&2
  exit 1
fi
if [ ! -x "$CHROMIUM_BIN" ]; then
  log "[vnc.sh:chromium] FATAL: configured CHROMIUM_BIN=${CHROMIUM_BIN} is not executable"
  echo "ERROR: configured Chromium binary ${CHROMIUM_BIN} is not executable — re-run installer." >&2
  exit 1
fi
CHROMIUM_REALPATH="$(readlink -f "$CHROMIUM_BIN" 2>/dev/null || echo "$CHROMIUM_BIN")"
case ":$(echo "$CHROMIUM_REALPATH" | tr '/' ':'):" in
  *:snap:*)
    log "[vnc.sh:chromium] FATAL: CHROMIUM_BIN=${CHROMIUM_BIN} resolves to ${CHROMIUM_REALPATH} (snap-confined)"
    echo "ERROR: configured Chromium ${CHROMIUM_BIN} resolves to ${CHROMIUM_REALPATH} which is snap-confined." >&2
    echo "       Snap AppArmor denies writes to ~/.{brand}/chromium-profile/. Re-run installer to install Google Chrome." >&2
    exit 1
    ;;
esac
CHROMIUM_CONFINEMENT="non-snap"
# The standing cdp-liveness probe runs every rss-sampler cycle; skip this
# per-invocation resolution line on that path so vnc-boot.log is not spammed.
# The FATAL guards above still run (and hard-exit) on every invocation.
if [ "${1:-}" != "cdp-liveness" ]; then
  log "[vnc.sh:chromium] bin=${CHROMIUM_BIN} realpath=${CHROMIUM_REALPATH} confinement=${CHROMIUM_CONFINEMENT}"
fi

kill_stale() {
  # Brand-scoped matchers: pkill on --user-data-dir narrows the
  # Chromium kill to this brand's profile only, so two brands on the same
  # device do not stomp on each other. Xtigervnc matcher narrows to this
  # brand's display number.
  pkill -f "chromium.*--user-data-dir=${CHROMIUM_PROFILE_DIR}" 2>/dev/null || true
  pkill -f "Xtigervnc ${VNC_DISPLAY}" 2>/dev/null || true
  pkill -f "websockify.*${WEBSOCKIFY_PORT}" 2>/dev/null || true
  rm -f "/tmp/.X${VNC_DISPLAY_NUM}-lock" "/tmp/.X11-unix/X${VNC_DISPLAY_NUM}"
  # Clear this brand's Chromium profile locks left by unclean shutdown.
  # Without this, Chromium refuses to start after a service restart or
  # power loss. The per-brand profile path means peer brands' locks are
  # never touched here.
  rm -f "${CHROMIUM_PROFILE_DIR}/SingletonLock" \
        "${CHROMIUM_PROFILE_DIR}/SingletonCookie" \
        "${CHROMIUM_PROFILE_DIR}/SingletonSocket" 2>/dev/null || true
  sleep 2

  # If any VNC-stack processes survived SIGTERM, force-kill them.
  local survivors=0
  pgrep -f "chromium.*--user-data-dir=${CHROMIUM_PROFILE_DIR}" >/dev/null 2>&1 && survivors=1
  pgrep -f "Xtigervnc ${VNC_DISPLAY}" >/dev/null 2>&1 && survivors=1
  pgrep -f "websockify.*${WEBSOCKIFY_PORT}" >/dev/null 2>&1 && survivors=1
  if [ "$survivors" -eq 1 ]; then
    log "SIGTERM survivors detected — sending SIGKILL"
    pkill -9 -f "chromium.*--user-data-dir=${CHROMIUM_PROFILE_DIR}" 2>/dev/null || true
    pkill -9 -f "Xtigervnc ${VNC_DISPLAY}" 2>/dev/null || true
    pkill -9 -f "websockify.*${WEBSOCKIFY_PORT}" 2>/dev/null || true
    sleep 1
  fi
}

# Refuse to start when this brand's display is already held by an unrelated
# process (e.g. a peer brand's Xtigervnc whose vncDisplay collides, or a
# manual Xvfb invocation). Loud-fail so the operator sees the held-by-pid
# rather than silently sharing a display with another stack..
check_display_collision() {
  local lock="/tmp/.X${VNC_DISPLAY_NUM}-lock"
  local socket="/tmp/.X11-unix/X${VNC_DISPLAY_NUM}"
  local held_pid=""
  if [ -f "$lock" ]; then
    held_pid="$(cat "$lock" 2>/dev/null | tr -d ' ' || true)"
  fi
  # Empty / stale lock → nothing to refuse. kill_stale will clean it up.
  if [ -z "$held_pid" ] || ! kill -0 "$held_pid" 2>/dev/null; then
    return 0
  fi
  # Live PID — but is it OUR own previous Xtigervnc? If yes, kill_stale will
  # handle it; only refuse when the holder is unrelated to this brand's
  # vnc.sh stack.
  if pgrep -f "Xtigervnc ${VNC_DISPLAY}" 2>/dev/null | grep -qx "$held_pid"; then
    return 0
  fi
  log "[vnc.sh:collision] brand=${BRAND_HOSTNAME} display=${VNC_DISPLAY} held-by-pid=${held_pid} socket=${socket}"
  echo "ERROR: display ${VNC_DISPLAY} is held by PID ${held_pid} (not this brand's Xtigervnc)" >&2
  echo "       Refusing to start; resolve the collision before retrying." >&2
  exit 1
}

# refuse to start when any of the three brand-scoped ports
# (rfbport, websockify, CDP) is held by an unrelated process. Mirrors
# check_display_collision's loud-fail format and exits non-zero so the
# operator sees the held-by-pid rather than silently sharing the port.
check_port_collision() {
  local label="$1" port="$2" matcher="$3"
  local held=""
  held=$(ss -tlnpH "sport = :$port" 2>/dev/null | grep -oE 'pid=[0-9]+' | head -1 | sed 's/pid=//')
  if [ -z "$held" ]; then return 0; fi
  if ! kill -0 "$held" 2>/dev/null; then return 0; fi
  # Held PID — is it our own brand's prior process? If yes, kill_stale will
  # handle it; only refuse when an unrelated PID owns the port.
  if [ -n "$matcher" ] && pgrep -f "$matcher" 2>/dev/null | grep -qx "$held"; then
    return 0
  fi
  log "[vnc.sh:collision] brand=${BRAND_HOSTNAME} ${label}=${port} held-by-pid=${held}"
  echo "ERROR: ${label} ${port} is held by PID ${held} (not this brand's stack)" >&2
  echo "       Refusing to start; resolve the collision before retrying." >&2
  exit 1
}

wait_for_port() {
  local port="$1" max="${2:-40}"
  for _ in $(seq 1 "$max"); do
    ss -tln "sport = :$port" | grep -q LISTEN && return 0
    sleep 0.3
  done
  return 1
}

# Return 0 if a visible Chromium window is mapped on the given X display.
# Used as a log-only observability invariant after CDP comes up (callers do
# NOT gate on the result). Matches the WM_CLASS of both Chromium (Pi .deb,
# class "chromium") and Google Chrome (Noble, class "google-chrome") via the
# [Cc]hrom regex. Degrades to non-zero when xdotool is absent or the display
# is non-X11, so the caller logs the existing non-gating "no window visible"
# warning rather than erroring (Wayland is handled by the caller, not here).
check_window_on_display() {
  local display="$1"
  command -v xdotool >/dev/null 2>&1 || return 1
  DISPLAY="$display" xdotool search --onlyvisible --class '[Cc]hrom' >/dev/null 2>&1
}

# Discover the login session's display type and display values.
# Uses loginctl (systemd) to bypass the poisoned $DISPLAY env var.
# Sets: NATIVE_SESSION_TYPE (wayland|x11), NATIVE_DISPLAY (:N),
#       NATIVE_WAYLAND_DISPLAY (wayland-0 or from session environ).
# Falls back to x11/:0 when loginctl is unavailable or no graphical session exists.
discover_native_session() {
  NATIVE_SESSION_TYPE="x11"
  NATIVE_DISPLAY=":0"
  NATIVE_WAYLAND_DISPLAY=""

  if ! command -v loginctl >/dev/null 2>&1; then
    log "WARNING: loginctl not found — falling back to DISPLAY=:0, ozone-platform=x11"
    return
  fi

  local session_id
  session_id=$(loginctl list-sessions --no-legend 2>/dev/null | awk 'NR==1{print $1}')
  if [ -z "$session_id" ]; then
    log "WARNING: no login sessions found — falling back to DISPLAY=:0, ozone-platform=x11"
    return
  fi

  local session_type
  session_type=$(loginctl show-session "$session_id" -p Type --value 2>/dev/null)

  local session_display
  session_display=$(loginctl show-session "$session_id" -p Display --value 2>/dev/null)

  if [ "$session_type" = "wayland" ]; then
    NATIVE_SESSION_TYPE="wayland"
    NATIVE_DISPLAY="${session_display:-:0}"

    # Discover WAYLAND_DISPLAY from the session leader's environment.
    local leader_pid
    leader_pid=$(loginctl show-session "$session_id" -p Leader --value 2>/dev/null)
    if [ -n "$leader_pid" ] && [ -r "/proc/$leader_pid/environ" ]; then
      NATIVE_WAYLAND_DISPLAY=$(tr '\0' '\n' < "/proc/$leader_pid/environ" 2>/dev/null | \
        grep '^WAYLAND_DISPLAY=' | cut -d= -f2)
    fi
    NATIVE_WAYLAND_DISPLAY="${NATIVE_WAYLAND_DISPLAY:-wayland-0}"
  elif [ -n "$session_type" ]; then
    NATIVE_SESSION_TYPE="$session_type"
    NATIVE_DISPLAY="${session_display:-:0}"
  else
    log "WARNING: loginctl returned empty session type — falling back to DISPLAY=:0, ozone-platform=x11"
  fi
}

start_chrome_on() {
  local target_display="$1"
  local label="$2"  # "vnc" (native mode uses start_chrome_native instead)
  # Brand-scoped Chromium kill: only this brand's profile-bound chromium.
  pkill -f "chromium.*--user-data-dir=${CHROMIUM_PROFILE_DIR}" 2>/dev/null || true
  sleep 0.3

  mkdir -p "${CHROMIUM_PROFILE_DIR}"
  log "Starting Chromium on ${target_display} (${label}) profile=${CHROMIUM_PROFILE_DIR} CDP=:${CDP_PORT}"

  DISPLAY="${target_display}" "$CHROMIUM_BIN" \
    --user-data-dir="${CHROMIUM_PROFILE_DIR}" \
    --ozone-platform=x11 \
    --no-sandbox \
    --test-type \
    --disable-dev-shm-usage \
    --disable-gpu \
    --password-store=basic \
    --use-mock-keychain \
    --no-first-run \
    --no-default-browser-check \
    --disable-background-networking \
    --disable-sync \
    --disable-default-apps \
    --disable-component-update \
    --disable-features=TranslateUI \
    --disable-session-crashed-bubble \
    --hide-crash-restore-bubble \
    --noerrdialogs \
    --metrics-recording-only \
    --window-size=1280,800 \
    --window-position=0,0 \
    --remote-debugging-port="${CDP_PORT}" \
    about:blank \
    >> "$LOG_FILE" 2>&1 &

  if wait_for_port "${CDP_PORT}"; then
    # Log-only observability: the display-membership invariant is
    # asserted but does NOT gate success here. Chromium does not D-Bus-delegate
    # its window creation, so the failure mode is not currently reachable — but
    # making the invariant visible now means future backend swaps (e.g.
    # snap-installed Chromium on Ubuntu with different IPC) surface the drift
    # in vnc-boot.log rather than as a silent-black iframe.
    if check_window_on_display "${target_display}"; then
      log "Chromium ready (${label}) with CDP on :${CDP_PORT} windowPresent=true"
    else
      log "WARNING: Chromium CDP up on :${CDP_PORT} but no window visible on ${target_display} (${label}) — observability-only, not gating"
    fi
  else
    log "ERROR: Chromium failed to start on ${target_display} (${label}) — CDP port ${CDP_PORT} not listening (browser-specialist degraded)"
  fi
}

start_chrome() {
  start_chrome_on "${VNC_DISPLAY}" "vnc"
}

# ---------------------------------------------------------------------------
# Retired in Task 287: the admin-UI terminal and browser-overlay surfaces
# are gone. This script still launches Chromium for legacy installs where
# the brand VNC is used for ad-hoc operator browsing; on maxy-code the
# admin chat PTY is the only interactive surface.
# ---------------------------------------------------------------------------

start_chrome_native() {
  discover_native_session

  # Brand-scoped Chromium kill: only this brand's profile-bound chromium.
  pkill -f "chromium.*--user-data-dir=${CHROMIUM_PROFILE_DIR}" 2>/dev/null || true
  sleep 0.3

  mkdir -p "${CHROMIUM_PROFILE_DIR}"
  log "Starting Chromium natively (${NATIVE_SESSION_TYPE}, $(
    if [ "$NATIVE_SESSION_TYPE" = "wayland" ]; then
      echo "WAYLAND_DISPLAY=${NATIVE_WAYLAND_DISPLAY}"
    else
      echo "DISPLAY=${NATIVE_DISPLAY}"
    fi
  )) profile=${CHROMIUM_PROFILE_DIR} CDP=:${CDP_PORT}"

  local ozone_flag="--ozone-platform=x11"
  local -a env_vars=("DISPLAY=${NATIVE_DISPLAY}")

  if [ "$NATIVE_SESSION_TYPE" = "wayland" ]; then
    ozone_flag="--ozone-platform=wayland"
    env_vars+=("WAYLAND_DISPLAY=${NATIVE_WAYLAND_DISPLAY}")
  fi

  env "${env_vars[@]}" "$CHROMIUM_BIN" \
    --user-data-dir="${CHROMIUM_PROFILE_DIR}" \
    "$ozone_flag" \
    --no-sandbox \
    --test-type \
    --disable-dev-shm-usage \
    --disable-gpu \
    --password-store=basic \
    --use-mock-keychain \
    --no-first-run \
    --no-default-browser-check \
    --disable-background-networking \
    --disable-sync \
    --disable-default-apps \
    --disable-component-update \
    --disable-features=TranslateUI \
    --disable-session-crashed-bubble \
    --hide-crash-restore-bubble \
    --noerrdialogs \
    --metrics-recording-only \
    --window-size=1280,800 \
    --window-position=0,0 \
    --remote-debugging-port="${CDP_PORT}" \
    about:blank \
    >> "$LOG_FILE" 2>&1 &

  if wait_for_port "${CDP_PORT}"; then
    # Log-only observability — see start_chrome_on for rationale.
    # On Wayland sessions xdotool cannot inspect the compositor, so this
    # check is x11-only; fall back to unconditional windowPresent=unknown.
    if [ "$NATIVE_SESSION_TYPE" = "x11" ] && check_window_on_display "${NATIVE_DISPLAY}"; then
      log "Chromium ready (native) with CDP on :${CDP_PORT} windowPresent=true"
    elif [ "$NATIVE_SESSION_TYPE" = "wayland" ]; then
      log "Chromium ready (native) with CDP on :${CDP_PORT} windowPresent=unknown (wayland)"
    else
      log "WARNING: Chromium CDP up on :${CDP_PORT} but no window visible on ${NATIVE_DISPLAY} (native) — observability-only, not gating"
    fi
  else
    log "ERROR: Chromium failed to start on ${NATIVE_DISPLAY} (native) — CDP port ${CDP_PORT} not listening (browser-specialist degraded)"
  fi
}

# ---------------------------------------------------------------------------
# Standing CDP-liveness supervisor (Task 1191).
#
# The browser plugin is a pure CDP client; when the automation Chromium dies
# (OOM victim, renderer crash) while the VNC display stays up, every browser-*
# tool returns cdp-unreachable until the brand service is bounced. This is a
# standing check on the rss-sampler cadence that reconciles expected-vs-actual
# — CDP reachable when the display is up — and re-runs the existing start_chrome
# path so the browser self-heals without a restart. It covers the no-event
# blind spot: Chromium exits silently, so transition-only logging cannot catch
# it; only a periodic probe can.
#
# Bounded: after RESPAWN_MAX_FAILS consecutive failed respawns it backs off for
# RESPAWN_COOLDOWN_SECS so an unlaunchable Chromium does not hot-loop (the
# snap-confinement FATAL guard at the top of this script already hard-exits
# before any respawn). Virtual-mode only: native mode launches Chromium
# on-demand, so display-up + CDP-down is its normal idle state, not a fault.
# ---------------------------------------------------------------------------
RESPAWN_STATE_FILE="${LOG_DIR}/cdp-respawn.state"
RESPAWN_MAX_FAILS="${RESPAWN_MAX_FAILS:-3}"
RESPAWN_COOLDOWN_SECS="${RESPAWN_COOLDOWN_SECS:-300}"

# DISPLAY_MODE is authoritative in the brand .env. The rss-sampler invokes this
# script without DISPLAY_MODE in its environment, so the env-var default would
# always read "virtual"; read the .env when the env var is unset.
resolve_display_mode() {
  local env_file="${MAXY_DIR}/.env" mode=""
  if [ -r "$env_file" ]; then
    mode=$(sed -n 's/^DISPLAY_MODE=//p' "$env_file" | tr -d '[:space:]' | head -n1)
  fi
  echo "${DISPLAY_MODE:-${mode:-virtual}}"
}

port_listening() {
  ss -tln "sport = :$1" 2>/dev/null | grep -q LISTEN
}

cdp_liveness() {
  # Native mode supervises nothing here — Chromium is launched on-demand
  # (ensureCdp), so display-up + CDP-down is the normal idle state, and a
  # respawn would launch an unsolicited browser on the operator's real display.
  if [ "$(resolve_display_mode)" = "native" ]; then
    return 0
  fi

  if port_listening "${CDP_PORT}"; then
    echo "[vnc] op=cdp-liveness display=${VNC_DISPLAY} cdp=:${CDP_PORT} reachable=true"
    rm -f "$RESPAWN_STATE_FILE" 2>/dev/null || true
    return 0
  fi
  echo "[vnc] op=cdp-liveness display=${VNC_DISPLAY} cdp=:${CDP_PORT} reachable=false"

  # Only the VNC-display Chromium is supervised. A down display is the
  # VNC-stack-restart case (ensureVnc, on the next browser call) — out of scope.
  port_listening "${RFB_PORT}" || return 0

  # Bounded respawn: read the consecutive-failure state.
  local fails=0 last=0 now
  now=$(date +%s)
  if [ -r "$RESPAWN_STATE_FILE" ]; then
    fails=$(sed -n 's/^fails=//p' "$RESPAWN_STATE_FILE" | head -n1)
    last=$(sed -n 's/^last=//p' "$RESPAWN_STATE_FILE" | head -n1)
    [[ "$fails" =~ ^[0-9]+$ ]] || fails=0
    [[ "$last" =~ ^[0-9]+$ ]] || last=0
  fi

  # At the failure cap and still inside the cooldown window → skip the respawn
  # so an unlaunchable Chromium does not hot-loop. The reachable=false line
  # above stays the sustained-failure signal; the FATAL reason is in
  # vnc-boot.log.
  if [ "$fails" -ge "$RESPAWN_MAX_FAILS" ] && [ "$(( now - last ))" -lt "$RESPAWN_COOLDOWN_SECS" ]; then
    return 0
  fi

  # Relaunch via the existing brand-scoped start_chrome path, then re-probe to
  # classify the outcome. start_chrome already blocks on wait_for_port, so the
  # CDP port state is settled when it returns; reading the port directly keeps
  # start_chrome's exit contract untouched (the reactive ensureCdp path in
  # vnc.ts consumes it).
  start_chrome || true
  if port_listening "${CDP_PORT}"; then
    echo "[vnc] op=chromium-respawn reason=cdp-unreachable cdp=:${CDP_PORT} outcome=ready"
    rm -f "$RESPAWN_STATE_FILE" 2>/dev/null || true
  else
    # Climb to the cap and hold there; bump last so the next attempt waits a
    # full cooldown rather than retrying every cycle.
    [ "$fails" -lt "$RESPAWN_MAX_FAILS" ] && fails=$(( fails + 1 ))
    printf 'fails=%s\nlast=%s\n' "$fails" "$now" > "$RESPAWN_STATE_FILE"
    echo "[vnc] op=chromium-respawn reason=cdp-unreachable cdp=:${CDP_PORT} outcome=failed"
  fi
}

case "${1:-}" in
  start)
    log "[vnc.sh] start brand=${BRAND_HOSTNAME} display=${VNC_DISPLAY} rfb=${RFB_PORT} websockify=${WEBSOCKIFY_PORT} cdp=${CDP_PORT} profile=${CHROMIUM_PROFILE_DIR} mode=${DISPLAY_MODE:-virtual}"
    # Collision checks run BEFORE kill_stale: kill_stale removes our own
    # locks/processes, so a check afterwards would always see them empty
    # and never refuse. The checks are no-ops for our own brand's prior
    # processes (kill_stale will reap them); they only refuse when an
    # unrelated process holds the display or one of the three ports.
    check_display_collision
    check_port_collision "rfbport" "${RFB_PORT}" "Xtigervnc ${VNC_DISPLAY}"
    check_port_collision "websockify" "${WEBSOCKIFY_PORT}" "websockify.*${WEBSOCKIFY_PORT}"
    check_port_collision "cdp" "${CDP_PORT}" "chromium.*--user-data-dir=${CHROMIUM_PROFILE_DIR}"
    kill_stale
    log "Starting Xtigervnc on ${VNC_DISPLAY} rfbport=${RFB_PORT}"

    Xtigervnc "${VNC_DISPLAY}" \
      -geometry 1280x800 \
      -depth 24 \
      -rfbport "${RFB_PORT}" \
      -localhost \
      -SecurityTypes None \
      -AlwaysShared \
      -BlacklistThreshold 1000000 \
      >> "$LOG_FILE" 2>&1 &

    if wait_for_port "${RFB_PORT}"; then
      log "Xtigervnc ready on :${RFB_PORT}"
    else
      log "ERROR: Xtigervnc failed to start — port ${RFB_PORT} not listening"
      exit 1
    fi

    # Guard against duplicate websockify — kill any stale instance before starting
    pkill -f "websockify.*${WEBSOCKIFY_PORT}" 2>/dev/null || true
    sleep 0.2

    websockify --web /usr/share/novnc "[::]:${WEBSOCKIFY_PORT}" "localhost:${RFB_PORT}" >> "$LOG_FILE" 2>&1 &

    if wait_for_port "${WEBSOCKIFY_PORT}"; then
      log "websockify ready on :${WEBSOCKIFY_PORT} → localhost:${RFB_PORT}"
    else
      log "ERROR: websockify failed to start — port ${WEBSOCKIFY_PORT} not listening"
      exit 1
    fi

    # Native mode: VNC stack runs for remote fallback, but Chromium
    # is launched on-demand by ensureCdp(transport) — not at boot.
    if [ "${DISPLAY_MODE:-virtual}" = "native" ]; then
      log "Native display mode — skipping boot-time Chromium (launched on-demand)"
    else
      start_chrome
    fi

    log "VNC + browser stack running"
    ;;

  start-chrome)
    start_chrome
    ;;

  start-chrome-native)
    start_chrome_native
    ;;

  cdp-liveness)
    cdp_liveness
    ;;

  stop)
    log "[vnc.sh] stop brand=${BRAND_HOSTNAME} display=${VNC_DISPLAY} rfb=${RFB_PORT} websockify=${WEBSOCKIFY_PORT}"
    kill_stale
    log "VNC stack stopped"
    ;;

  status)
    ss -tln "sport = :${RFB_PORT}" | grep -q LISTEN && echo "running" || echo "stopped"
    ;;

  *)
    echo "Usage: vnc.sh start | stop | start-chrome | start-chrome-native | cdp-liveness | status" >&2
    exit 1
    ;;
esac
