#!/bin/bash
# Classroom v2 test lab driver.
#
# The development host is a VM with no /dev/kvm, so the desktop under test
# runs inside a systemd container instead of a virtual machine. Nothing this
# script does touches the host outside Docker.
#
# The session is nested: an outer headless sway exists only to give the
# desktop under test a display that grim can capture and that sway's IPC can
# aim a real pointer at. The outer compositor is lab equipment, not part of
# the classroom design.
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
NAME="${LAB_NAME:-classroom-v2-lab}"
IMAGE="${LAB_IMAGE:-classroom-v2-lab:plasma}"
DOCKERFILE="${LAB_DOCKERFILE:-tools/lab/Dockerfile.plasma}"
SHOTS="${LAB_SHOTS:-$ROOT/docs/screenshots}"

OUTER=wayland-1

session_env() {
    docker exec -u classroom \
        -e XDG_RUNTIME_DIR=/run/user/1000 \
        -e HOME=/home/classroom \
        -e LANG=C.UTF-8 \
        -e DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
        "$@"
}

# Talk to the outer lab compositor: grim, swaymsg.
outer() {
    local sock
    sock=$(docker exec "$NAME" sh -c 'ls /run/user/1000/sway-ipc.* 2>/dev/null | head -1')
    session_env -e WAYLAND_DISPLAY="$OUTER" -e SWAYSOCK="$sock" "$NAME" "$@"
}

# NOTE ON XDG_CONFIG_DIRS.
#
# This harness used to hand the session
# XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg, and every result it
# produced was therefore a result about a machine somebody had already
# configured by hand. A real session is started by SDDM through PAM, which
# does not read /etc/environment.d, and the classroom's settings were not
# in the cascade at all: the first real upgrade came up as stock Debian
# Plasma with a lock screen. The variable is deliberately not set here any
# more. The package has to put it there, the way it has to on a machine.

# Talk to the Plasma session under test.
inner() {
    session_env -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE \
        -e QT_QPA_PLATFORM=wayland "$NAME" "$@"
}

case "${1:-}" in
build)
    shift
    exec docker build -f "$ROOT/$DOCKERFILE" -t "$IMAGE" "$@" "$ROOT"
    ;;

up)
    docker rm -f "$NAME" >/dev/null 2>&1 || true
    docker run -d --name "$NAME" \
        --privileged --cgroupns=private \
        --tmpfs /run --tmpfs /run/lock \
        --shm-size=1g \
        -v "$ROOT:/srv/classroom:ro" \
        "$IMAGE" >/dev/null
    for _ in $(seq 1 90); do
        state=$(docker exec "$NAME" systemctl is-system-running 2>/dev/null || true)
        [[ "$state" == running || "$state" == degraded ]] && break
        sleep 1
    done
    for _ in $(seq 1 30); do
        docker exec "$NAME" test -S /run/user/1000/bus && break
        sleep 1
    done
    # startplasma looks for `qdbus`; Debian ships it as qdbus6.
    docker exec "$NAME" ln -sf /usr/bin/qdbus6 /usr/local/bin/qdbus

    # The church's own networks, refused from inside the container.
    #
    # This is not theoretical. The moment the package began defaulting
    # MQTT_HOST to the church broker, the lab connected to it: a test
    # machine appearing on the real broker under a container id. Nothing
    # was left behind — the broker held no retained status afterwards,
    # which was checked — but the lab had no business being there at all,
    # and the Parent Call coordinator is on the same networks.
    #
    # Container-local: this netns belongs to the container alone and
    # nothing on the host or the church network is changed by it. Debian's
    # own mirrors are untouched, so installs still work.
    # 10.10 is the printers. The package provisions Elmer at
    # socket://10.10.0.155:9100, and the development host reaches that
    # address over Tailscale, so an unfenced lab that printed would have
    # put paper in the church's real printer.
    #
    # A blackhole route rather than a firewall rule: the lab image has no
    # iptables, and the rig's guard had been quietly doing nothing because
    # of it. Routes need no netfilter modules and cannot silently fail.
    docker exec "$NAME" sh -c '
        for net in 10.10.0.0/16 10.30.0.0/16 10.40.0.0/16; do
            ip route replace blackhole "$net" || exit 1
        done' || echo "  WARNING: could not fence the church networks"

    # And checked with bash, not sh. The probe uses /dev/tcp, which is a
    # bash feature: under dash it fails whatever the network is doing, so
    # the check passed on a lab that could reach the broker perfectly
    # well. Measured.
    for target in 10.40.0.2/1883 10.30.0.2/1025 10.10.0.155/9100; do
        if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
            echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
            exit 1
        fi
    done
    echo "  church networks fenced out of the lab"
    echo "lab $NAME is up ($(docker exec "$NAME" systemctl is-system-running 2>/dev/null))"
    ;;

down)
    docker rm -f "$NAME" >/dev/null 2>&1 || true
    echo "lab $NAME is down"
    ;;

reboot)
    # As close to a room being switched off and on as a container gets:
    # the same filesystem, a new PID 1, a new user manager, and every
    # generator and drop-in read again from scratch. The migration work
    # needs this, because half of what an upgrade changes is only read at
    # boot.
    docker restart "$NAME" >/dev/null
    for _ in $(seq 1 90); do
        state=$(docker exec "$NAME" systemctl is-system-running 2>/dev/null || true)
        [[ "$state" == running || "$state" == degraded ]] && break
        sleep 1
    done
    for _ in $(seq 1 30); do
        docker exec "$NAME" test -S /run/user/1000/bus && break
        sleep 1
    done
    docker exec "$NAME" ln -sf /usr/bin/qdbus6 /usr/local/bin/qdbus

    # The fence again. Routes do not survive a container restart, and the
    # lab reached the church broker through exactly that gap: four rooms
    # named after container ids on the real broker, from sessions that
    # had been fenced when they started and were not after a reboot.
    docker exec "$NAME" sh -c '
        for net in 10.10.0.0/16 10.30.0.0/16 10.40.0.0/16; do
            ip route replace blackhole "$net" || exit 1
        done' || echo "  WARNING: could not fence the church networks"

    for target in 10.40.0.2/1883 10.30.0.2/1025 10.10.0.155/9100; do
        if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
            echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
            exit 1
        fi
    done

    # SDDM really does autologin here: it authenticates the classroom
    # through PAM and launches the Plasma session, and then has no display
    # hardware to draw it on. That is worth having — it is the real login
    # path, not a reading of a configuration file — so the attempt is left
    # to happen and its evidence stays in the journal. It is then stopped,
    # because the harness runs its own session on the nested compositor
    # and two sessions cannot both own the keyboard.
    for _ in $(seq 1 30); do
        docker exec "$NAME" journalctl -u sddm --no-pager 2>/dev/null \
            | grep -q "Session started" && break
        sleep 1
    done
    docker exec "$NAME" systemctl stop sddm >/dev/null 2>&1 || true
    docker exec "$NAME" pkill -f sddm-helper >/dev/null 2>&1 || true
    # -f, not -x. "startplasma-wayland" is longer than the fifteen
    # characters a process name has, so -x silently matches nothing and
    # SDDM's session was left winding down underneath the harness's own:
    # it tore down graphical-session.target sixteen seconds later and took
    # the panel with it, which cost an afternoon to understand.
    docker exec "$NAME" pkill -f startplasma-wayland >/dev/null 2>&1 || true
    docker exec "$NAME" pkill -f plasma-dbus-run-session >/dev/null 2>&1 || true
    for _ in $(seq 1 20); do
        state=$(docker exec -u classroom -e XDG_RUNTIME_DIR=/run/user/1000 "$NAME" \
            systemctl --user is-active graphical-session.target 2>/dev/null || true)
        [[ "$state" != active ]] && break
        sleep 1
    done
    sleep 2
    echo "lab $NAME rebooted ($(docker exec "$NAME" systemctl is-system-running 2>/dev/null || true))"
    ;;

outer-up)
    # A headless compositor has no input devices, and a wl_seat with no
    # pointer capability never gives the session under test a pointer to
    # click with — sway's cursor commands then report success and move
    # nothing. So the lab runs wlroots' libinput backend as well, over the
    # devices the privileged container inherits, with seatd standing in for
    # the login session it does not have. Clicks in this experiment are
    # real pointer events arriving through libinput.
    docker exec "$NAME" sh -c 'pgrep -x seatd >/dev/null || \
        (SEATD_VTBOUND=0 setsid seatd -g input > /tmp/seatd.log 2>&1 &)'
    docker exec "$NAME" usermod -aG input classroom 2>/dev/null || true
    sleep 2
    session_env \
        -e WLR_BACKENDS=headless,libinput -e LIBSEAT_BACKEND=seatd \
        -e WLR_RENDERER=pixman -e LIBGL_ALWAYS_SOFTWARE=1 \
        -d "$NAME" sh -c 'exec sway -c /srv/classroom/tools/lab/outer-sway.conf \
            > /tmp/outer-sway.log 2>&1'
    for _ in $(seq 1 30); do
        docker exec "$NAME" test -S "/run/user/1000/$OUTER" && break
        sleep 1
    done
    echo "outer compositor on $OUTER"
    ;;

plasma-up)
    session_env \
        -e WAYLAND_DISPLAY="$OUTER" -e XDG_SESSION_TYPE=wayland \
        -e XDG_CURRENT_DESKTOP=KDE -e LIBGL_ALWAYS_SOFTWARE=1 \
        -e QT_LOGGING_RULES="kwin_scripting.debug=true" \
        -d "$NAME" sh -c 'exec startplasma-wayland > /tmp/plasma.log 2>&1'
    for _ in $(seq 1 60); do
        docker exec "$NAME" pgrep -x plasmashell >/dev/null 2>&1 && break
        sleep 1
    done
    sleep 5
    echo "plasma session up"
    ;;

plasma-down)
    # A logout, not a killing. plasmashell and kwin are both services of
    # the user manager, and killing them only makes it start them again —
    # the compositor came straight back holding wayland-0 and the next
    # session could not start at all. The workspace target has to go down
    # first, and this is what a real logout does too.
    session_env "$NAME" systemctl --user stop plasma-workspace.target \
        >/dev/null 2>&1 || true
    docker exec "$NAME" pkill -u classroom -f startplasma-wayland || true
    session_env "$NAME" systemctl --user stop graphical-session.target \
        >/dev/null 2>&1 || true
    for _ in $(seq 1 20); do
        docker exec "$NAME" pgrep -x kwin_wayland >/dev/null 2>&1 || break
        sleep 1
    done
    docker exec "$NAME" pkill -u classroom -x kwin_wayland >/dev/null 2>&1 || true
    docker exec "$NAME" pkill -u classroom -x plasmashell >/dev/null 2>&1 || true
    sleep 2
    echo "plasma session down"
    ;;

session)
    "$0" outer-up
    "$0" plasma-up
    ;;

shot)
    # shot NAME [GEOMETRY]  — geometry as grim wants it, e.g. "0,980 1920x100"
    name="${2:?usage: lab shot NAME}"
    if [[ -n "${3:-}" ]]; then
        outer grim -g "$3" /tmp/lab-shot.png >/dev/null
    else
        outer grim /tmp/lab-shot.png >/dev/null
    fi
    mkdir -p "$SHOTS"
    docker exec "$NAME" cat /tmp/lab-shot.png > "$SHOTS/$name.png"
    echo "$SHOTS/$name.png"
    ;;

click)
    # Absolute, through sway's own seat. This only works because the seat
    # has a pointer capability at all, which is what the uinput device in
    # `outer-up` is for.
    outer swaymsg seat - cursor set "$2" "$3" >/dev/null
    sleep 0.4
    outer swaymsg seat - cursor press button1 >/dev/null
    sleep 0.15
    outer swaymsg seat - cursor release button1 >/dev/null
    sleep 0.5
    ;;

rclick)
    outer swaymsg seat - cursor set "$2" "$3" >/dev/null
    sleep 0.4
    outer swaymsg seat - cursor press button3 >/dev/null
    sleep 0.15
    outer swaymsg seat - cursor release button3 >/dev/null
    sleep 0.5
    ;;

move)
    outer swaymsg seat - cursor set "$2" "$3" >/dev/null
    ;;

drag)
    outer swaymsg seat - cursor set "$2" "$3" >/dev/null
    sleep 0.3
    outer swaymsg seat - cursor press button1 >/dev/null
    sleep 0.3
    outer swaymsg seat - cursor set "$4" "$5" >/dev/null
    sleep 0.3
    outer swaymsg seat - cursor release button1 >/dev/null
    sleep 0.4
    ;;

key)
    shift
    outer wtype "$@"
    ;;

sh)
    shift
    exec docker exec -it "$NAME" bash "$@"
    ;;

run)      shift; session_env "$NAME" "$@" ;;
irun)     shift; session_env -it "$NAME" "$@" ;;
inner)    shift; inner "$@" ;;
inner-d)  shift; session_env -d -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE \
              -e QT_QPA_PLATFORM=wayland "$NAME" "$@" ;;
inner-it) shift; session_env -it -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE "$NAME" "$@" ;;
outer)    shift; outer "$@" ;;

*)
    cat <<USAGE
usage: tools/lab/lab COMMAND

  build            build the lab image
  up               start the lab container (systemd inside)
  down             remove it
  reboot           restart the machine, let SDDM autologin, then stand down
  session          outer compositor + a Plasma session on top of it
  outer-up         just the outer compositor
  plasma-up        just the Plasma session
  plasma-down      stop the Plasma session
  shot NAME        capture the screen to docs/screenshots/NAME.png
  click X Y        press the pointer at a screen position
  drag X1 Y1 X2 Y2 drag from one position to another
  move X Y         move the pointer
  key ARGS...      wtype, aimed at the session
  inner CMD...     run CMD as a client of the Plasma session
  inner-d CMD...   the same, detached
  outer CMD...     run CMD against the lab compositor
  run CMD...       run CMD as the classroom user, no display
  sh               root shell in the lab
USAGE
    exit 1
    ;;
esac
