#!/usr/bin/env bash
#
# LLM Host Setup — Ollama, docs/project-plan.md Phase 3
# Target: Debian 12 (Bookworm), a SEPARATE physical machine from the container host.
#
# Stands up the one thing this host exists for: an Ollama server on the LAN that
# everything else in this project calls for inference —
#   - Home Assistant's Ollama conversation integration (Assist, AI Task)  [Phase 3/4]
#   - digest-engine's quarter-daily synthesis + counter-run               [Phase 12]
#   - pantry-vision's grocery-item recognition (vision model)             [Phase 17]
#   - chores' bin/dishes/litter checks + reminder phrasing                [Phase 20]
#
# THE GUARDRAIL THIS HOST IS BUILT AROUND: **everything above must degrade to
# "unavailable", never to "broken", when this machine is off.** docs/project-plan.md's
# testing checklist states it directly — "Does the reactive path (presence → light on)
# work with the LLM host powered off? (It must.)" — and every consumer above already
# falls back to a deterministic template, a plain lookup table, or simply skipping the
# run. Nothing here should ever become load-bearing for the reactive smart-home
# baseline. That is why this is a separate host at all: so it CAN be off.
#
# Two hardware tiers, auto-detected (override with TIER below):
#   gpu — NVIDIA card present. Runs the 14B-class model per Phase 3.
#   cpu — no GPU. Runs a 7B/3B model. Slower (single-digit tok/s), enough to validate
#         the whole pipeline before spending money on a card, per docs/components.md's
#         "Fallback: skip GPU" line.
#
# Run as: sudo ./setup-llm-host.sh
#
# EDIT THE VARIABLES BELOW BEFORE RUNNING.

set -euo pipefail

# ---------------------------------------------------------------------------
# CONFIGURATION
#
# Every value here is `${VAR:-default}`, so anything already in the environment wins —
# which is how the ISO from tools/build-llm-host-iso.sh configures this script without
# editing it (its first-boot unit sources /opt/llm-host/llm-host.env, generated from
# CoreSystemConfig.json). Edit these directly ONLY for a standalone run.
# ---------------------------------------------------------------------------
BASE_DIR="${BASE_DIR:-/opt/llm-host}"            # Config + model storage. Models are BIG (a 14B
                                    # Q4 model is ~9GB, a vision model another 5-8GB)
                                    # — make sure this lives on a disk with room.

TIER="${TIER:-auto}"                         # auto | gpu | cpu

OLLAMA_PORT="${OLLAMA_PORT:-11434}"                 # Ollama's own default.

# Models to pull, per tier. Phase 3 specifies Qwen2.5-14B-Instruct (GPU) or 7B/3B
# (CPU). Tags are Ollama library names — `ollama list` on a real host to confirm what
# you actually ended up with, since library tags do get renamed upstream.
GPU_TEXT_MODEL="${GPU_TEXT_MODEL:-qwen2.5:14b-instruct}"
CPU_TEXT_MODEL="${CPU_TEXT_MODEL:-qwen2.5:7b-instruct}"

# The vision model, for pantry-vision (grocery items) and chores (bin/dishes/litter).
# NOT A CONSIDERED CHOICE — `llava` is the default those services already ship with,
# and docs/project-plan.md open decision #18 flags the pick as unmade and completely
# unbenchmarked. Treat this as "something to measure", not "the answer": if grocery
# recognition is too slow or too wrong to be usable, this is the first knob to turn
# (qwen2.5vl and moondream are the obvious alternatives to try).
VISION_MODEL="${VISION_MODEL:-llava}"

PULL_VISION_MODEL="${PULL_VISION_MODEL:-true}"            # false to skip — saves several GB if you're not
                                    # running pantry-vision/chores camera checks yet.

# --- Contention between interactive and batch callers ------------------------------
# The real scheduling problem on one GPU (docs/project-plan.md open decision #4):
# Assist is INTERACTIVE (a person is standing there waiting), while digest-engine is
# BATCH (every 6h, nobody watching) and the vision callers are occasional but want a
# DIFFERENT model resident. Defaults below optimise for the interactive case, because
# that's the one where latency is felt:
#
# KEEP_ALIVE — how long a model stays resident after its last request. Ollama's own
#   default is 5m, which means a household that talks to Assist a few times an hour
#   pays the model-load cost almost every time. 30m keeps it warm through normal use.
OLLAMA_KEEP_ALIVE="${OLLAMA_KEEP_ALIVE:-30m}"
# MAX_LOADED_MODELS — how many distinct models may be resident at once. **1 is
#   deliberate on a single consumer GPU**: a 14B text model and a vision model do not
#   fit together in 8-12GB, and letting Ollama try produces VRAM thrash or an OOM
#   mid-request rather than an honest swap. 1 means "swap predictably, pay the reload
#   cost when the vision model is actually needed." Raise it only if you have the VRAM
#   to hold both and have checked that you do.
OLLAMA_MAX_LOADED_MODELS="${OLLAMA_MAX_LOADED_MODELS:-1}"
# NUM_PARALLEL — concurrent requests served per loaded model. 1 keeps latency
#   predictable for whoever is speaking to Assist; higher trades that for throughput
#   nothing in this project currently needs.
OLLAMA_NUM_PARALLEL="${OLLAMA_NUM_PARALLEL:-1}"

# ---------------------------------------------------------------------------
# End of configuration
# ---------------------------------------------------------------------------

log()  { echo -e "\n\033[1;34m==>\033[0m $*"; }
warn() { echo -e "\033[1;33m[warn]\033[0m $*" >&2; }
die()  { echo -e "\033[1;31m[error]\033[0m $*" >&2; exit 1; }

[[ $EUID -eq 0 ]] || die "Run this with sudo."

# ---------------------------------------------------------------------------
# Tier detection
# ---------------------------------------------------------------------------
detect_tier() {
  if [[ "$TIER" != "auto" ]]; then
    echo "$TIER"
    return
  fi
  # nvidia-smi existing AND succeeding are different things — a leftover driver
  # package on a machine whose card has been pulled would satisfy `command -v` alone
  # and send us down the GPU path to fail later at container start.
  if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1; then
    echo "gpu"
  else
    echo "cpu"
  fi
}

# ---------------------------------------------------------------------------
# Docker — same install path as tools/setup-container-host.sh.
# Ollama is run as a container rather than natively installed for the same reason
# everything else in this project is: no `curl | sh` into a root shell, a pinned
# image, and an uninstall that's `docker rm`. The native installer is a legitimate
# alternative (see README.md); it is not the default here.
# ---------------------------------------------------------------------------
install_docker() {
  if command -v docker >/dev/null 2>&1; then
    log "Docker already installed — skipping"
    return
  fi
  log "Installing Docker"
  apt-get update
  apt-get install -y ca-certificates curl gnupg
  install -m 0755 -d /etc/apt/keyrings
  curl -fsSL https://download.docker.com/linux/debian/gpg \
    | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
  chmod a+r /etc/apt/keyrings/docker.gpg
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
    > /etc/apt/sources.list.d/docker.list
  apt-get update
  apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
}

# ---------------------------------------------------------------------------
# NVIDIA Container Toolkit — only what lets Docker see the GPU. It does NOT install
# the driver: a working `nvidia-smi` is a prerequisite this script checks for rather
# than tries to satisfy, because driver installation is the single most
# hardware/kernel-specific step on this host and silently picking a driver version
# for someone is a good way to produce an unbootable machine.
# ---------------------------------------------------------------------------
install_nvidia_toolkit() {
  if ! nvidia-smi >/dev/null 2>&1; then
    die "TIER=gpu but nvidia-smi doesn't work. Install the NVIDIA driver first
     (Debian: enable non-free-firmware, then 'apt install nvidia-driver firmware-misc-nonfree',
     reboot, confirm 'nvidia-smi' prints your card), or set TIER=cpu to run without a GPU."
  fi
  if command -v nvidia-ctk >/dev/null 2>&1; then
    log "NVIDIA Container Toolkit already installed — skipping"
  else
    log "Installing NVIDIA Container Toolkit"
    curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
      | gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
    curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
      | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
      > /etc/apt/sources.list.d/nvidia-container-toolkit.list
    apt-get update
    apt-get install -y nvidia-container-toolkit
  fi
  nvidia-ctk runtime configure --runtime=docker
  systemctl restart docker
}

# ---------------------------------------------------------------------------
# Compose file
# ---------------------------------------------------------------------------
write_compose() {
  local tier="$1"
  local gpu_block=""

  if [[ "$tier" == "gpu" ]]; then
    gpu_block="
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]"
  fi

  mkdir -p "$BASE_DIR/models"

  cat > "$BASE_DIR/docker-compose.yml" <<EOF
# Generated by tools/setup-llm-host.sh — re-running the script
# regenerates this file. Tier: ${tier}
services:
  ollama:
    image: ollama/ollama:latest
    container_name: ollama
    restart: unless-stopped
    ports:
      # Published on ALL interfaces so the container host, HA, and the kiosks can
      # reach it over the LAN — this host exists to serve them. See README.md's
      # security note: Ollama has NO authentication, so the VLAN is the boundary.
      - "${OLLAMA_PORT}:11434"
    volumes:
      - ${BASE_DIR}/models:/root/.ollama
    environment:
      # Inside the container Ollama must listen on 0.0.0.0, not its default
      # 127.0.0.1 — otherwise the published port above forwards to a socket nothing
      # is listening on, and every caller gets a connection refused that looks
      # exactly like "the host is down".
      - OLLAMA_HOST=0.0.0.0:11434
      - OLLAMA_KEEP_ALIVE=${OLLAMA_KEEP_ALIVE}
      - OLLAMA_MAX_LOADED_MODELS=${OLLAMA_MAX_LOADED_MODELS}
      - OLLAMA_NUM_PARALLEL=${OLLAMA_NUM_PARALLEL}
      - OLLAMA_MODELS=/root/.ollama/models${gpu_block}
EOF
  log "Wrote $BASE_DIR/docker-compose.yml"
}

# ---------------------------------------------------------------------------
# Model pulls
# ---------------------------------------------------------------------------
pull_model() {
  local model="$1"
  log "Pulling $model (this can take a long while — several GB)"
  # Pulls run INSIDE the already-running container so they land on the mounted
  # models volume and are visible to the server without a restart.
  if ! docker exec ollama ollama pull "$model"; then
    warn "Could not pull '$model'. The server is still up — pull it by hand later with:
       docker exec ollama ollama pull $model
     If the tag was renamed upstream, check https://ollama.com/library for the current one."
    return 1
  fi
}

wait_for_ollama() {
  log "Waiting for Ollama to answer on :${OLLAMA_PORT}"
  for _ in $(seq 1 60); do
    if curl -fsS "http://127.0.0.1:${OLLAMA_PORT}/api/tags" >/dev/null 2>&1; then
      return 0
    fi
    sleep 2
  done
  die "Ollama didn't come up within 2 minutes. Check: docker logs ollama"
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
TIER_RESOLVED="$(detect_tier)"
log "Tier: ${TIER_RESOLVED}$([[ "$TIER" == "auto" ]] && echo " (auto-detected)")"

if [[ "$TIER_RESOLVED" == "cpu" ]]; then
  warn "Running CPU-only. Expect single-digit tokens/sec — usable for validating the
     whole pipeline end to end, slow enough to be annoying for live Assist. This is
     docs/components.md's deliberate 'skip the GPU for now' fallback, not a failure."
fi

install_docker
[[ "$TIER_RESOLVED" == "gpu" ]] && install_nvidia_toolkit

write_compose "$TIER_RESOLVED"

log "Starting Ollama"
docker compose -f "$BASE_DIR/docker-compose.yml" up -d
wait_for_ollama

if [[ "$TIER_RESOLVED" == "gpu" ]]; then
  TEXT_MODEL="$GPU_TEXT_MODEL"
else
  TEXT_MODEL="$CPU_TEXT_MODEL"
fi

# A failed pull is deliberately not fatal — the server is up and useful, and the
# likeliest cause is a renamed upstream tag, which a human fixes in seconds and a
# script cannot guess at.
pull_model "$TEXT_MODEL" || true
if [[ "$PULL_VISION_MODEL" == "true" ]]; then
  pull_model "$VISION_MODEL" || true
fi

HOST_IP="$(hostname -I | awk '{print $1}')"

log "Installed models:"
docker exec ollama ollama list || warn "Could not list models"

cat <<EOF

============================================================================
 LLM host is up:  http://${HOST_IP}:${OLLAMA_PORT}
============================================================================

Wire it into the rest of the stack — everything below wants that URL:

 1. Home Assistant (Phase 3): Settings -> Devices & Services -> Add Integration
    -> Ollama, URL http://${HOST_IP}:${OLLAMA_PORT}, model ${TEXT_MODEL}.
    Then Settings -> Voice assistants -> your Assist pipeline -> Conversation agent.

 2. On the CONTAINER host, in each service's env file:
      digest-engine.env : OLLAMA_HOST=http://${HOST_IP}:${OLLAMA_PORT}
                          OLLAMA_MODEL=${TEXT_MODEL}
      pantry-vision.env : OLLAMA_HOST=http://${HOST_IP}:${OLLAMA_PORT}
                          OLLAMA_VISION_MODEL=${VISION_MODEL}
      chores.env        : OLLAMA_HOST=http://${HOST_IP}:${OLLAMA_PORT}
                          OLLAMA_VISION_MODEL=${VISION_MODEL}
                          OLLAMA_TEXT_MODEL=${TEXT_MODEL}   (optional, reminder phrasing)

    NOTE the full scheme+port form: those services build URLs by string
    concatenation, so a bare IP will not work.

 3. THEN DO THE THING THIS HOST EXISTS TO SURVIVE — power it off and confirm the
    reactive baseline still works:
      - presence -> light on/off still fires (Phase 2 automations, no LLM in the loop)
      - the door panel still shows weather/who's-home
      - chores still nudges (plain template instead of LLM-phrased wording)
      - digest-engine skips its run rather than erroring the timer
    docs/project-plan.md's testing checklist calls this out explicitly. If anything
    above BREAKS rather than degrading, that's a bug in the consumer, not here.

 4. SECURITY: Ollama has no authentication of any kind, and its API can pull and
    DELETE models, not just generate. Anyone who can reach :${OLLAMA_PORT} can do all
    of that. Keep this host on the smart-home VLAN, never port-forwarded — see
    docs/network-integration.md.

EOF
