#!/usr/bin/env bash
set -euo pipefail

project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
default_deck="${DECKROOM_DEFAULT_LECTURE:-}"
state_root="${DECKROOM_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/deckroom}"
pid_file="$state_root/server.pid"
target_file="$state_root/target"
log_pointer_file="$state_root/current_log"
web_port_file="$state_root/web_port"
preview_port_file="$state_root/preview_port"
firefox_path="${DECKROOM_FIREFOX_PATH:-/Applications/Firefox.app/Contents/MacOS/firefox}"
profile_path="${DECKROOM_FIREFOX_PROFILE:-${XDG_DATA_HOME:-$HOME/.local/share}/deckroom/firefox_profile}"
lecture_profile_path="${DECKROOM_LECTURE_FIREFOX_PROFILE:-${XDG_DATA_HOME:-$HOME/.local/share}/deckroom/lecture_firefox_profile}"
launchd_label="${DECKROOM_LAUNCHD_LABEL:-hk.hku.deckroom.presenter}"
default_web_port="${DECKROOM_WEB_PORT:-2804}"
default_preview_port="${DECKROOM_PORT:-4318}"

usage() {
  cat <<'EOF'
Usage: bin/deckroom <command> [lecture]

Commands:
  doctor [lecture]   Check this Mac before a lecture
  start <lecture>    Replace any current session, then open the lecture
  status             Show the local server and current lecture
  logs [--follow]    Show the current session log
  restart [lecture]  Restart, retaining the current lecture when omitted
  stop               Gracefully stop the current session from any Deckroom copy
  lectures <root>    Discover manifest-based courses and lectures
  validate <lecture> Validate a lecture manifest, lock, and current toolchain
  lock <lecture>     Create a lock; add --update only for an explicit relock
  migrate <lecture>  Report a toolchain migration; add --apply to test and apply
  preview <lecture> [slides]  Render source; optionally refresh only slides such as 10 or 9-11
  build <lecture> <output>  Render a locked lecture in isolation
  certify-runtime [fixtures]  Certify this Deckroom runtime once for releases
  certify-visuals <lecture>  Certify AILT1001 L2 Slide 1/2 parity in visible Firefox
  release <lecture> [output]  Build a numbered instructor release
  help               Show this help

Lecture may be a Quarto .qmd source file or a portable .deckroom directory.
Environment overrides: DECKROOM_STATE_DIR, DECKROOM_PORT, DECKROOM_WEB_PORT,
DECKROOM_FIREFOX_PATH, DECKROOM_FIREFOX_PROFILE, DECKROOM_FIREFOX_HEADLESS,
DECKROOM_LECTURE_FIREFOX_PROFILE, DECKROOM_LECTURE_FIREFOX_HEADLESS,
DECKROOM_DEFAULT_LECTURE, DECKROOM_PREVIEW_TIMEOUT_MS,
DECKROOM_STARTUP_ATTEMPTS.

The local Deckroom Status address defaults to http://127.0.0.1:2804/.
In source mode Quarto defaults to internal port 4318.
EOF
}

authoring_tool() {
  local script="$1"
  shift
  if [[ ! -f "$project_dir/companion/$script" ]]; then
    echo "This command is available only in a Deckroom development checkout." >&2
    return 1
  fi
  node "$project_dir/companion/$script" "$@"
}

ensure_state_dir() {
  mkdir -p "$state_root"
  chmod 700 "$state_root" 2>/dev/null || true
}

read_file() {
  local file_name="$1"
  local value
  [[ -r "$file_name" ]] && IFS= read -r value < "$file_name" && printf '%s' "$value"
}

managed_pid() {
  local candidate command_line job_description submitted_pid
  submitted_pid="$(launchd_pid || true)"
  candidate="${submitted_pid:-$(read_file "$pid_file" || true)}"
  [[ "$candidate" =~ ^[0-9]+$ ]] || return 1
  kill -0 "$candidate" 2>/dev/null || return 1
  command_line="$(ps -p "$candidate" -o command= 2>/dev/null || true)"
  if [[ -n "$submitted_pid" ]]; then
    job_description="$(launchd_job_description || true)"
    is_deckroom_launchd_job "$job_description" || return 1
    [[ "$command_line" == *"/companion/present.mjs"* ]] || return 1
    write_state_value "$pid_file" "$candidate"
  else
    [[ "$command_line" == *"$project_dir/companion/present.mjs"* ]] || return 1
  fi
  printf '%s' "$candidate"
}

launchd_job_description() {
  launchctl print "gui/$(id -u)/$launchd_label" 2>/dev/null
}

launchd_pid() {
  launchd_job_description |
    awk '/^[[:space:]]*pid = [0-9]+/ { print $3; exit }'
}

is_deckroom_launchd_job() {
  local job_description="${1:-}"
  [[ "$job_description" == *"DECKROOM_MANAGED=1"* ]] &&
    [[ "$job_description" == *"DECKROOM_LAUNCHD_LABEL=$launchd_label"* ]] &&
    [[ "$job_description" == *"/bin/present_deck"* ]]
}

remove_stale_launchd_job() {
  local job_description
  job_description="$(launchd_job_description || true)"
  [[ -n "$job_description" ]] || return 0
  if ! is_deckroom_launchd_job "$job_description"; then
    echo "Refusing to replace an unexpected launchd job named $launchd_label." >&2
    return 1
  fi
  if ! launchctl remove "$launchd_label" >/dev/null 2>&1; then
    [[ -z "$(launchd_job_description || true)" ]] && return 0
    echo "Could not remove the verified Deckroom service $launchd_label." >&2
    return 1
  fi
  for _ in $(seq 1 50); do
    [[ -z "$(launchd_job_description || true)" ]] && return 0
    sleep 0.1
  done
  echo "The verified Deckroom service $launchd_label is still registered." >&2
  return 1
}

write_state_value() {
  local destination="$1"
  local value="$2"
  local temporary="${destination}.tmp.$$"
  printf '%s\n' "$value" > "$temporary"
  mv "$temporary" "$destination"
}

canonical_target() {
  local requested="${1:-}"
  if [[ -z "$requested" ]]; then
    if [[ -n "$default_deck" && ( -f "$default_deck" || -d "$default_deck" ) ]]; then
      requested="$default_deck"
    else
      echo "A lecture .qmd file or portable .deckroom directory is required." >&2
      return 1
    fi
  fi
  if [[ -d "$requested" ]]; then
    (cd "$requested" && pwd)
  elif [[ -f "$requested" ]]; then
    local parent_dir
    parent_dir="$(cd "$(dirname "$requested")" && pwd)"
    printf '%s/%s\n' "$parent_dir" "$(basename "$requested")"
  else
    echo "Lecture not found: $requested" >&2
    return 1
  fi
}

target_kind() {
  local lecture="$1"
  if [[ -d "$lecture" && -f "$lecture/deckroom.json" ]]; then
    printf 'bundle'
  elif [[ -f "$lecture" && "$lecture" == *.qmd ]]; then
    printf 'source'
  else
    printf 'invalid'
  fi
}

health_json() {
  local web_port="$1"
  /usr/bin/curl --silent --show-error --fail --max-time 1 "http://127.0.0.1:${web_port}/health" 2>/dev/null
}

lecture_status_json() {
  local web_port="$1"
  /usr/bin/curl --silent --show-error --fail --max-time 1 "http://127.0.0.1:${web_port}/v2/status" 2>/dev/null
}

health_ready() {
  local web_port="$1"
  local health=""
  health="$(health_json "$web_port")" || return 1
  node -e 'const health=JSON.parse(process.argv[1]);process.exit(health.ok===true&&health.ready===true?0:1)' "$health" 2>/dev/null
}

port_owner() {
  local port_number="$1"
  command -v lsof >/dev/null 2>&1 || return 1
  lsof -nP -iTCP:"$port_number" -sTCP:LISTEN -t 2>/dev/null | head -1
}

doctor_command() {
  ensure_state_dir
  local lecture="${1:-}"
  local existing_session_policy="${2:-require-ready}"
  local failures=0
  local kind=""
  local node_compatible=""
  local preview_port="$default_preview_port"
  local web_port="$default_web_port"
  local running_web_port=""

  echo "Deckroom preflight"

  if command -v node >/dev/null 2>&1; then
    node_compatible="$(node -p 'Number([22, 24, 26].includes(Number(process.versions.node.split(".")[0])))')"
    if [[ "$node_compatible" == 1 ]]; then
      echo "  OK  Node $(node --version)"
    else
      echo "  FAIL Supported Node.js 22, 24, or 26 is required; Node 24 LTS is recommended (found $(node --version))"
      failures=$((failures + 1))
    fi
  else
    echo "  FAIL Node is not installed"
    failures=$((failures + 1))
  fi

  if [[ -x "$firefox_path" ]]; then
    echo "  OK  Firefox: $firefox_path"
  else
    echo "  FAIL Firefox not found: $firefox_path"
    failures=$((failures + 1))
  fi

  if command -v launchctl >/dev/null 2>&1; then
    echo "  OK  macOS service manager: launchctl"
  else
    echo "  FAIL launchctl is required for managed background sessions"
    failures=$((failures + 1))
  fi

  if (cd "$project_dir" && node --input-type=module -e 'await import("ws")') >/dev/null 2>&1; then
    echo "  OK  Runtime dependency: ws"
  else
    echo "  FAIL Runtime dependencies are missing; run npm ci --omit=dev"
    failures=$((failures + 1))
  fi

  if [[ -n "$lecture" || ( -n "$default_deck" && ( -f "$default_deck" || -d "$default_deck" ) ) ]]; then
    if lecture="$(canonical_target "$lecture")"; then
      kind="$(target_kind "$lecture")"
      if [[ "$kind" == "bundle" ]]; then
        if node --input-type=module -e '
          import { pathToFileURL } from "node:url";
          const moduleUrl = pathToFileURL(process.argv[2]).href;
          const { validatePortableBundle } = await import(moduleUrl);
          await validatePortableBundle(process.argv[1]);
        ' "$lecture" "$project_dir/companion/bundle.mjs" >/dev/null 2>&1; then
          echo "  OK  Portable lecture: $lecture"
        else
          echo "  FAIL Invalid bundle manifest: $lecture/deckroom.json"
          failures=$((failures + 1))
        fi
      elif [[ "$kind" == "source" ]]; then
        echo "  OK  Quarto source: $lecture"
        if command -v quarto >/dev/null 2>&1; then
          echo "  OK  Quarto $(quarto --version)"
        else
          echo "  FAIL Quarto is required for source lectures"
          failures=$((failures + 1))
        fi
        if [[ -f "$project_dir/companion/lecture_cli.mjs" ]] &&
           node "$project_dir/companion/lecture_cli.mjs" validate "$lecture" >/dev/null 2>&1; then
          echo "  OK  Self-contained lecture manifest, lock, extension, and Quarto configuration"
        else
          echo "  FAIL Source lecture is not a valid self-contained package"
          failures=$((failures + 1))
        fi
      else
        echo "  FAIL Lecture must be a .qmd file or .deckroom directory"
        failures=$((failures + 1))
      fi
    else
      failures=$((failures + 1))
    fi
  else
    echo "  NOTE No lecture supplied; runtime-only checks completed"
  fi

  if managed_pid >/dev/null 2>&1; then
    running_web_port="$(read_file "$web_port_file" || true)"
    if [[ "$existing_session_policy" == "replace" ]]; then
      echo "  OK  Existing Deckroom session will be replaced"
    elif health_ready "${running_web_port:-$default_web_port}"; then
      echo "  OK  Deckroom is already running and ready"
    else
      echo "  FAIL Deckroom has a managed process but is not ready"
      failures=$((failures + 1))
    fi
  else
    local preview_owner web_owner
    preview_owner="$(port_owner "$preview_port" || true)"
    web_owner="$(port_owner "$web_port" || true)"
    if [[ -n "$preview_owner" && "$kind" == "source" ]]; then
      echo "  FAIL Preview port $preview_port is already used by PID $preview_owner"
      failures=$((failures + 1))
    else
      echo "  OK  Preview port $preview_port"
    fi
    if [[ -n "$web_owner" ]]; then
      echo "  FAIL Companion port $web_port is already used by PID $web_owner"
      failures=$((failures + 1))
    else
      echo "  OK  Companion port $web_port"
    fi
  fi

  if [[ "$failures" -gt 0 ]]; then
    echo "Preflight failed with $failures problem(s)." >&2
    return 1
  fi
  echo "Preflight passed."
}

status_command() {
  ensure_state_dir
  local pid lecture session_log web_port health lecture_status service_state
  if ! pid="$(managed_pid)"; then
    echo "Deckroom Service: stopped"
    session_log="$(read_file "$log_pointer_file" || true)"
    [[ -n "$session_log" ]] && echo "Last session log: $session_log"
    return 1
  fi
  lecture="$(read_file "$target_file" || true)"
  session_log="$(read_file "$log_pointer_file" || true)"
  web_port="$(read_file "$web_port_file" || true)"
  health="$(health_json "${web_port:-$default_web_port}" || true)"
  lecture_status="$(lecture_status_json "${web_port:-$default_web_port}" || true)"
  service_state="degraded"
  if [[ -n "$health" ]] && node -e '
    const health=JSON.parse(process.argv[1]);
    process.exit(health.ok===true&&health.ready===true?0:1);
  ' "$health" 2>/dev/null; then
    service_state="ready"
  fi
  echo "Deckroom Service: $service_state"
  if [[ -n "$health" && -n "$lecture_status" ]]; then
    node -e '
      const health = JSON.parse(process.argv[1]);
      const status = JSON.parse(process.argv[2]);
      const stateLabel = status.state === "ready" ? "ready" :
        status.state === "attention" ? "needs attention" :
        status.state === "starting" ? "starting" :
        status.state === "recovering" ? "recovering" :
        status.state === "restarting" ? "restarting" : "unavailable";
      const headline = typeof status.headline === "string" && status.headline
        ? status.headline : "status details unavailable";
      const connected = (value) => value === true ? "connected" :
        value === false ? "not connected" : "unavailable";
      const source = status.projection?.source === "demo" ? "Demo" :
        status.projection?.source === "slides" ? "Slides" : "unavailable";
      const selectedVisibility = status.projection?.visibility === "private" ? "Private" :
        status.projection?.visibility === "on-screen" ? "On screen" : "unavailable";
      const visibility = status.projection?.safetyReason
        ? `${selectedVisibility} (Safety screen)` : selectedVisibility;
      const demoState = status.demo?.state === "ready" ? "ready" :
        status.demo?.state === "attention" ? "needs attention" :
        status.demo?.state === "offline" ? "offline" :
        status.demo?.state === "starting" ? "starting" : "unavailable";
      const tabs = Number.isInteger(status.demo?.expectedTabs)
        ? Number.isInteger(status.demo?.readyTabs)
          ? `${status.demo.readyTabs} of ${status.demo.expectedTabs} tabs ready`
          : `readiness unavailable (${status.demo.expectedTabs} tabs expected)`
        : "tab count not reported yet";
      console.log(`Lecture setup: ${stateLabel} - ${headline}`);
      console.log(`Audience output: ${visibility} - ${source}`);
      console.log(`Presenter Console: ${connected(status.windows?.controller)}`);
      console.log(`Audience Window: ${connected(status.windows?.output)}`);
      console.log(`Demo Browser: ${demoState} - ${tabs}`);
      console.log(`Demo capture stream: ${typeof status.demo?.capture === "string" && status.demo.capture ? status.demo.capture : "unavailable"}`);
      const recoveryLabels = {
        "safe-screen": "showing the neutral Audience safety screen",
        "presentation-reconnect": "reloading Presenter Console and Audience Window",
        "demo-browser-restart": "restarting Demo Browser",
        "deckroom-restart": "restarting Deckroom Service",
      };
      const recovery = status.recovery?.active
        ? recoveryLabels[status.recovery.active] || "in progress"
        : "idle";
      console.log(`Recovery operation: ${recovery}`);
      console.log(`Session ID: ${health.deckId || "unavailable"}`);
      console.log(`Protocol version: ${status.protocol ?? health.protocol ?? "unavailable"}`);
      console.log(`State schema: ${status.stateSchema ?? "unavailable"}`);
    ' "$health" "$lecture_status"
  else
    echo "Lecture setup: status unavailable"
  fi
  echo "Service process ID: $pid"
  [[ -n "$lecture" ]] && echo "Lecture files: $lecture"
  echo "Deckroom Status: http://127.0.0.1:${web_port:-$default_web_port}/"
  [[ -n "$web_port" ]] && echo "Service health check: http://127.0.0.1:${web_port}/health"
  [[ -n "$health" ]] && echo "Raw service health: $health" || echo "Raw service health: starting or unavailable"
  [[ -n "$session_log" ]] && echo "Session log: $session_log"
  [[ "$service_state" == "ready" ]]
}

start_command() {
  ensure_state_dir
  local existing_job lecture kind preview_port web_port timestamp session_log pid startup_attempts startup_timeout_seconds
  lecture="$(canonical_target "${1:-}")"
  kind="$(target_kind "$lecture")"
  [[ "$kind" != "invalid" ]] || { echo "Unsupported lecture: $lecture" >&2; return 1; }
  doctor_command "$lecture" replace
  existing_job="$(launchd_job_description || true)"
  if pid="$(managed_pid)"; then
    echo "Replacing the existing Deckroom session (PID $pid)…"
    stop_command
  elif [[ -n "$existing_job" ]]; then
    echo "Clearing the previous Deckroom startup before starting a new session…"
    stop_command
  fi
  preview_port="$default_preview_port"
  web_port="$default_web_port"
  timestamp="$(date '+%Y%m%d_%H%M%S')"
  session_log="$state_root/session_${timestamp}.log"
  : > "$session_log"
  chmod 600 "$session_log" 2>/dev/null || true

  remove_stale_launchd_job
  launchctl submit -l "$launchd_label" -o "$session_log" -e "$session_log" -- \
    /usr/bin/env \
      "PATH=$PATH" \
      "HOME=$HOME" \
      "DECKROOM_PORT=$preview_port" \
      "DECKROOM_WEB_PORT=$web_port" \
      "DECKROOM_FIREFOX_PATH=$firefox_path" \
      "DECKROOM_FIREFOX_PROFILE=$profile_path" \
      "DECKROOM_FIREFOX_HEADLESS=${DECKROOM_FIREFOX_HEADLESS:-1}" \
      "DECKROOM_LECTURE_FIREFOX_PROFILE=$lecture_profile_path" \
      "DECKROOM_LECTURE_FIREFOX_HEADLESS=${DECKROOM_LECTURE_FIREFOX_HEADLESS:-0}" \
      "DECKROOM_PREVIEW_TIMEOUT_MS=${DECKROOM_PREVIEW_TIMEOUT_MS:-180000}" \
      "DECKROOM_LAUNCHD_LABEL=$launchd_label" \
      "DECKROOM_MANAGED=1" \
      "$project_dir/bin/present_deck" "$lecture"
  pid=""
  for _ in $(seq 1 50); do
    pid="$(launchd_pid || true)"
    [[ "$pid" =~ ^[0-9]+$ ]] && break
    sleep 0.1
  done
  if [[ ! "$pid" =~ ^[0-9]+$ ]]; then
    echo "Deckroom could not obtain a managed process ID. Recent log:" >&2
    tail -40 "$session_log" >&2
    remove_stale_launchd_job
    return 1
  fi
  write_state_value "$pid_file" "$pid"
  write_state_value "$target_file" "$lecture"
  write_state_value "$log_pointer_file" "$session_log"
  write_state_value "$web_port_file" "$web_port"
  write_state_value "$preview_port_file" "$preview_port"

  echo "Starting Deckroom for: $lecture"
  startup_attempts="${DECKROOM_STARTUP_ATTEMPTS:-420}"
  [[ "$startup_attempts" =~ ^[1-9][0-9]*$ ]] || {
    echo "DECKROOM_STARTUP_ATTEMPTS must be a positive integer." >&2
    remove_stale_launchd_job
    rm -f "$pid_file"
    return 1
  }
  for _ in $(seq 1 "$startup_attempts"); do
    pid="$(launchd_pid || true)"
    if [[ ! "$pid" =~ ^[0-9]+$ ]]; then
      echo "Deckroom stopped during startup. Recent log:" >&2
      tail -40 "$session_log" >&2
      rm -f "$pid_file"
      return 1
    fi
    if health_ready "$web_port"; then
      write_state_value "$pid_file" "$pid"
      echo "Deckroom is ready in Firefox."
      if status_command; then return 0; fi
      echo "Deckroom lost readiness during startup verification." >&2
      return 1
    fi
    sleep 0.5
  done
  startup_timeout_seconds=$(((startup_attempts + 1) / 2))
  echo "Deckroom did not become ready within ${startup_timeout_seconds} seconds. Recent log:" >&2
  tail -40 "$session_log" >&2
  remove_stale_launchd_job
  rm -f "$pid_file"
  return 1
}

stop_command() {
  ensure_state_dir
  local job_description pid submitted_pid stop_attempts stubborn_command
  if ! pid="$(managed_pid)"; then
    job_description="$(launchd_job_description || true)"
    submitted_pid="$(launchd_pid || true)"
    if [[ "$submitted_pid" =~ ^[0-9]+$ ]]; then
      echo "Stopping Deckroom while it is starting (PID $submitted_pid)…"
      remove_stale_launchd_job
      rm -f "$pid_file"
      echo "Deckroom stopped."
      return 0
    fi
    if [[ -n "$job_description" ]]; then
      if ! is_deckroom_launchd_job "$job_description"; then
        echo "Refusing to replace an unexpected launchd job named $launchd_label." >&2
        return 1
      fi
      echo "Removing the stopped Deckroom service…"
      remove_stale_launchd_job
      rm -f "$pid_file"
      echo "Deckroom stopped."
      return 0
    fi
    echo "Deckroom is already stopped."
    rm -f "$pid_file"
    return 0
  fi
  echo "Stopping Deckroom (PID $pid)…"
  remove_stale_launchd_job
  stop_attempts="${DECKROOM_STOP_ATTEMPTS:-100}"
  for _ in $(seq 1 "$stop_attempts"); do
    if ! kill -0 "$pid" 2>/dev/null; then
      rm -f "$pid_file"
      echo "Deckroom stopped."
      return 0
    fi
    sleep 0.1
  done
  echo "Deckroom did not stop gracefully; terminating verified PID $pid…" >&2
  stubborn_command="$(ps -p "$pid" -o command= 2>/dev/null || true)"
  if [[ "$stubborn_command" != *"/companion/present.mjs"* ]]; then
    echo "PID $pid no longer belongs to Deckroom; refusing forced termination." >&2
    return 1
  fi
  kill -KILL "$pid" 2>/dev/null || true
  for _ in $(seq 1 20); do
    if ! kill -0 "$pid" 2>/dev/null; then
      rm -f "$pid_file"
      echo "Deckroom stopped."
      return 0
    fi
    sleep 0.1
  done
  echo "Deckroom PID $pid is still running after forced termination." >&2
  return 1
}

logs_command() {
  ensure_state_dir
  local session_log
  session_log="$(read_file "$log_pointer_file" || true)"
  if [[ -z "$session_log" || ! -f "$session_log" ]]; then
    echo "No Deckroom session log is available." >&2
    return 1
  fi
  if [[ "${1:-}" == "--follow" || "${1:-}" == "-f" ]]; then
    exec tail -n 80 -f "$session_log"
  fi
  tail -n 80 "$session_log"
}

preview_command() {
  local lecture source_dir source_name slide_selection
  lecture="$(canonical_target "${1:-}")"
  [[ "$(target_kind "$lecture")" == "source" ]] || {
    echo "preview requires a Quarto .qmd lecture source." >&2
    return 1
  }
  command -v quarto >/dev/null 2>&1 || {
    echo "Quarto is required to render a source lecture." >&2
    return 1
  }
  source_dir="$(dirname "$lecture")"
  source_name="$(basename "$lecture")"
  slide_selection="${2:-}"
  if [[ -n "$slide_selection" ]]; then
    echo "Rendering $source_name and refreshing preview slide(s): $slide_selection"
    (cd "$source_dir" && DECKROOM_PREVIEW_SLIDES="$slide_selection" quarto render "$source_name")
    return
  fi
  echo "Rendering $source_name and refreshing all slide previews"
  (cd "$source_dir" && quarto render "$source_name")
}

restart_command() {
  ensure_state_dir
  local lecture="${1:-}"
  if [[ -z "$lecture" ]]; then
    lecture="$(read_file "$target_file" || true)"
  fi
  [[ -n "$lecture" ]] || { echo "No previous lecture is recorded; pass a .qmd file or .deckroom directory." >&2; return 1; }
  start_command "$lecture"
}

command_name="${1:-help}"
shift || true
case "$command_name" in
  doctor) doctor_command "${1:-}" ;;
  start) start_command "${1:-}" ;;
  status) status_command ;;
  logs) logs_command "${1:-}" ;;
  restart) restart_command "${1:-}" ;;
  stop) stop_command ;;
  lectures)
    [[ -n "${1:-}" ]] || { echo "lectures requires a course-content root." >&2; exit 2; }
    authoring_tool lecture_cli.mjs catalog "$1"
    ;;
  validate)
    [[ -n "${1:-}" ]] || { echo "validate requires a lecture folder, manifest, or .qmd file." >&2; exit 2; }
    authoring_tool lecture_cli.mjs validate "$1"
    ;;
  lock)
    [[ -n "${1:-}" ]] || { echo "lock requires a lecture folder, manifest, or .qmd file." >&2; exit 2; }
    authoring_tool lecture_cli.mjs lock "$1" "${@:2}"
    ;;
  migrate)
    [[ -n "${1:-}" ]] || { echo "migrate requires a lecture folder, manifest, or .qmd file." >&2; exit 2; }
    authoring_tool lecture_cli.mjs migrate "$1" "${@:2}"
    ;;
  preview)
    [[ -n "${1:-}" ]] || { echo "preview requires a lecture source." >&2; exit 2; }
    preview_command "$1" "${2:-}"
    ;;
  build)
    [[ -n "${1:-}" && -n "${2:-}" ]] || { echo "build requires a lecture and output directory." >&2; exit 2; }
    authoring_tool build_lecture.mjs "$1" "$2"
    ;;
  certify-runtime)
    authoring_tool certify_runtime.mjs "$@"
    ;;
  certify-visuals)
    [[ -n "${1:-}" ]] || { echo "certify-visuals requires a lecture folder, manifest, or .qmd file." >&2; exit 2; }
    authoring_tool certify_slide_visuals.mjs "$@"
    ;;
  release)
    [[ -n "${1:-}" ]] || { echo "release requires a lecture folder, manifest, or .qmd file." >&2; exit 2; }
    "$project_dir/bin/build_release" "$@"
    ;;
  help|-h|--help) usage ;;
  *) echo "Unknown command: $command_name" >&2; usage >&2; exit 2 ;;
esac
