#!/bin/bash
# This checkout's emulator: which one it is, whether there is room for it,
# and getting it up and down.
#
# The AVD is named after the enclosing git checkout and nobody has to say so
# (see `project_avd`). That is the whole point: several agent sessions work on
# this machine at once, and an emulator named after the checkout is one that
# cannot be somebody else's to install onto or take the foreground from.
#
# `up` refuses rather than tries when memory is short, which is not caution
# for its own sake. On 2026-08-30 an emulator started with 2.8 GB available
# invoked the OOM killer, and what it took was not the emulator that had just
# started: it walked the user slice and killed pipewire, dbus-broker and
# another session's running emulator first. The cost of one too many lands on
# somebody else's work, minutes later, looking like an unrelated crash.
set -euo pipefail

. "$(dirname "$(readlink -f "$0")")/../lib/project-avd.sh"

# What one costs, measured: 3.8 GB resident for a headless x86_64 AVD, plus
# what it grows into while an app builds and installs.
EMU_EXPECTED_MB=${EMU_EXPECTED_MB:-4200}
# What must be left afterwards. A machine at exactly zero swaps itself to a
# halt rather than failing cleanly.
EMU_HEADROOM_MB=${EMU_HEADROOM_MB:-1200}
# Two are comfortable on 23 GB; three is what invoked the OOM killer.
EMU_MAX_RUNNING=${EMU_MAX_RUNNING:-2}

DEVICE_PROFILE=${DEVICE_PROFILE:-pixel_10}
SYSTEM_IMAGE=${SYSTEM_IMAGE:-system-images;android-36;google_apis;x86_64}

sdk=$(android_sdk) || {
    echo "emu: no Android SDK found; set ANDROID_HOME" >&2
    exit 127
}
adb=$(android_adb)
avd=$(project_avd)
export ANDROID_AVD_HOME="${ANDROID_AVD_HOME:-$HOME/.android/avd}"

usage() {
    cat >&2 <<'USAGE'
usage: emu <command>

  name     the AVD this directory means
  serial   its adb serial, if it is running (exit 1 if not)
  list     every emulator attached, with its AVD and what it is costing
  up       start it, refusing if the machine has no room
  down     stop it
  check    refuse if a command that reaches every attached device would
           reach another checkout's emulator from here

Environment: EMU_GPU=software renders on the CPU instead of the host's GPU,
AVD_NAME overrides the name, EMU_FORCE=1 overrides the memory refusal,
EMU_ANY_DEVICE=1 overrides `check`, DEVICE_PROFILE and SYSTEM_IMAGE decide
what `up` creates.
USAGE
    exit 2
}

# Every emulator's serial, AVD and resident size -- the last being the number
# the refusal below is about, so it is worth seeing before asking for another.
cmd_list() {
    found=false
    while IFS=$'\t' read -r serial name; do
        found=true
        # Matched on the AVD name the emulator was started with, which is
        # what its process actually carries -- there is no -port in that
        # command line, and the console port in the serial does not appear
        # in it. Filtered on the process *name* rather than on the whole
        # line, so this cannot match the shell running it: that shell's
        # command line contains this pattern, and a `ps | awk` that matches
        # itself reports the size of the shell.
        # Whole argument rather than a prefix, for the reason `cmd_up`
        # gives: with both up, a substring match reported ai-app-2's size
        # against ai-app, which is a number that looks entirely plausible.
        rss=$(ps -eo rss=,comm=,args= |
            awk -v want="-avd $name" '$2 ~ /^qemu-system/ &&
                match($0, want "([[:space:]]|$)") {print int($1/1024); exit}')
        printf '  %-16s %-24s %s\n' "$serial" "$name" "${rss:+$rss MB}"
    done < <(running_avds "$adb")
    [ "$found" = true ] || echo "  (none attached)"
}

# Whether starting one now is likely to cost somebody else their work.
#
# Reported in full rather than as a bare refusal: the caller cannot see this
# machine, and "not enough memory" without the numbers or a way forward is a
# message that gets overridden blind.
check_room() {
    available=$(mem_available_mb)
    needed=$((EMU_EXPECTED_MB + EMU_HEADROOM_MB))
    running=$(running_avds "$adb" | wc -l)
    problem=""
    if [ "$running" -ge "$EMU_MAX_RUNNING" ]; then
        problem="$running emulator(s) are already running, and $EMU_MAX_RUNNING is as many as this machine takes"
    elif [ "$available" -lt "$needed" ]; then
        problem="$available MB available, and one of these needs about $EMU_EXPECTED_MB MB plus $EMU_HEADROOM_MB MB of headroom"
    fi
    [ -z "$problem" ] && return 0

    if [ "${EMU_FORCE:-}" = 1 ]; then
        echo "emu: $problem -- starting anyway because EMU_FORCE=1" >&2
        return 0
    fi
    {
        echo "emu: not starting '$avd' -- $problem."
        echo
        echo "  Emulators attached:"
        cmd_list
        echo
        echo "  Biggest processes:"
        ps -eo rss,comm --sort=-rss | awk 'NR>1 && NR<=6 {printf "    %-24s %6.0f MB\n", $2, $1/1024}'
        echo
        echo "  What usually frees enough:"
        echo "    gradle --stop            (an idle Gradle daemon holds 1-2 GB between builds)"
        echo "    emu down                 (in the checkout that owns an emulator you are done with)"
        echo "    ask the session that owns one -- they are named after their checkout"
        echo "    EMU_FORCE=1 emu up       (if you have decided the numbers above are wrong)"
    } >&2
    exit 1
}

# Host Vulkan is off, and this is the one flag here that is about a bug
# rather than a preference.
#
# gfxstream asks the host for a memory type it can use for its ColorBuffers,
# and Venus -- the virtio-gpu Vulkan driver this VM sees the real GPU
# through -- does not offer one: "Format VK_FORMAT_R8G8B8A8_UNORM is not
# supported", then "Failed to find memory type for ColorBuffers", then the
# emulator dies before adb ever sees it. GLES on the same driver is fine, and
# is what the guest falls back to, so the only thing lost is Vulkan inside
# the guest. Try dropping this whenever mesa moves: the failure is loud and
# immediate, so it costs one boot to find out.
GPU_HOST_FEATURES=${GPU_HOST_FEATURES:--feature -Vulkan}

# An X server on this machine's GPU, started once and reused, or nothing.
#
# The emulator's `-gpu host` renderer talks GLX. With no DISPLAY it cannot
# open one -- "GlxEnginegetDefaultDisplay: Failed to open display 0" -- and
# the only other thing it will do is rasterise in software. Measured on this
# VM on 2026-08-30, that is not a small difference: scrolling a list in the
# stock Settings app went from 25% janky frames to 3%, and the emulator's own
# resident size fell from 3.9 GB to 2.9 GB.
#
# So: a headless sway, purely to get an Xwayland whose GLX runs on the
# virtio-gpu render node. It needs no seat, no DRM master and no card node --
# only /dev/dri/renderD128, which is world-readable here. Writes nothing
# outside XDG_RUNTIME_DIR and goes away with the machine.
#
# Prints the DISPLAY it ended up with; exit 1 means the caller should fall
# back to software rather than fail, because none of this is essential.
headless_display() {
    command -v sway >/dev/null 2>&1 || return 1
    [ -e /dev/dri/renderD128 ] || return 1

    run="${XDG_RUNTIME_DIR:-/tmp}/emulator-tools"
    mkdir -p "$run"
    export SWAYSOCK="$run/sway.sock"
    # Named rather than left to sway's pid-based default, which is what makes
    # a second `emu up` reuse the first one's compositor instead of starting
    # another beside it.
    if ! swaymsg -t get_version >/dev/null 2>&1; then
        rm -f "$SWAYSOCK"
        conf="$(dirname "$(readlink -f "$0")")/../share/emu/sway.conf"
        WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 LIBSEAT_BACKEND=noop \
            setsid sway -c "$conf" >"$run/sway.log" 2>&1 &
        for _ in $(seq 20); do
            swaymsg -t get_version >/dev/null 2>&1 && break
            sleep 0.5
        done
        swaymsg -t get_version >/dev/null 2>&1 || {
            echo "emu: could not start the headless compositor; see $run/sway.log" >&2
            return 1
        }
    fi

    # Asked of the compositor rather than guessed, because Xwayland takes the
    # first free display number and this machine may already have one.
    rm -f "$run/display"
    swaymsg exec -- "sh -c 'printf %s \"\$DISPLAY\" > $run/display'" >/dev/null 2>&1
    for _ in $(seq 20); do
        [ -s "$run/display" ] && break
        sleep 0.5
    done
    [ -s "$run/display" ] || return 1
    cat "$run/display"
}

cmd_up() {
    if serial=$(avd_serial "$adb" "$avd"); then
        echo "emu: '$avd' is already running ($serial)" >&2
        echo "$serial"
        return 0
    fi
    check_room

    if [ ! -f "$ANDROID_AVD_HOME/$avd.ini" ]; then
        echo "emu: creating AVD '$avd' ($DEVICE_PROFILE, $SYSTEM_IMAGE)" >&2
        "$sdk/cmdline-tools/latest/bin/sdkmanager" "emulator" "$SYSTEM_IMAGE" >/dev/null ||
            echo "emu: sdkmanager could not confirm the system image; trying anyway" >&2
        echo no | "$sdk/cmdline-tools/latest/bin/avdmanager" create avd \
            -n "$avd" -k "$SYSTEM_IMAGE" --device "$DEVICE_PROFILE" --sdcard 512M >&2
    fi

    # The host keyboard, so anything with a text field can be typed into
    # rather than tapped out on the on-screen one.
    config="$ANDROID_AVD_HOME/$avd.avd/config.ini"
    if [ -f "$config" ] && ! grep -qx 'hw.keyboard=yes' "$config"; then
        grep -v '^hw\.keyboard=' "$config" >"$config.tmp"
        echo "hw.keyboard=yes" >>"$config.tmp"
        mv "$config.tmp" "$config"
    fi

    # A stray process for this AVD that never registered with adb. The
    # bracketed first character keeps the pattern from matching the shell
    # running this script, which would kill it mid-flight.
    #
    # The trailing boundary is the other half, and it is not decoration: an
    # AVD name is a *prefix* of every longer one, so `-avd ai-app` matched
    # `-avd ai-app-2` and starting one checkout's emulator silently killed
    # another checkout's. That is the exact interference this whole tool
    # exists to prevent, and it looked from the other side like an emulator
    # dying on its own -- twice, on 2026-08-31, before the pattern was the
    # suspect. Match the name as a whole argument, never as a prefix.
    pkill -f "[e]mulator.*-avd $avd([[:space:]]|\$)" >/dev/null 2>&1 || true

    log="/tmp/$avd-emulator.log"
    rm -f "$log"
    # `-gpu host` runs the guest's GLES on this machine's real GPU, which here
    # means the *host's* GPU reached through virtio-gpu -- so a headless
    # emulator nobody is watching still renders onto the desktop somebody is
    # sitting at. It stays the default because it was measured (25% janky
    # frames against 3%, and 3.9 GB resident against 2.9 GB) and the Compose
    # scroll benchmarks depend on it. EMU_GPU=software opts out, for work
    # where the emulator's own frame rate is not what is under test: it
    # renders on the CPU and touches no GPU at all, which also means the
    # guest gets a software Vulkan device (SwiftShader) instead of the host
    # Vulkan that gfxstream cannot pair with Venus here.
    if [ "${EMU_GPU:-}" != software ] && { [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; }; then
        echo "emu: starting '$avd' with GPU acceleration" >&2
        "$sdk/emulator/emulator" -avd "$avd" -gpu host -no-audio >"$log" 2>&1 &
    elif [ "${EMU_GPU:-}" != software ] && display=$(headless_display); then
        echo "emu: no display -- starting '$avd' headless on the GPU ($display)" >&2
        DISPLAY="$display" "$sdk/emulator/emulator" -avd "$avd" \
            -gpu host $GPU_HOST_FEATURES -no-audio -no-window >"$log" 2>&1 &
    else
        if [ "${EMU_GPU:-}" = software ]; then
            echo "emu: EMU_GPU=software -- starting '$avd' on the CPU, off the host GPU" >&2
        else
            echo "emu: no display and no compositor -- starting '$avd' on software rendering" >&2
        fi
        "$sdk/emulator/emulator" -avd "$avd" -gpu swiftshader_indirect -no-audio -no-window \
            >"$log" 2>&1 &
    fi
    pid=$!

    for _ in $(seq 150); do
        if ! kill -0 "$pid" 2>/dev/null; then
            echo "emu: the emulator exited before it finished booting:" >&2
            tail -20 "$log" >&2
            exit 1
        fi
        if serial=$(avd_serial "$adb" "$avd"); then
            booted=$("$adb" -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
            if [ "$booted" = 1 ]; then
                echo "emu: '$avd' booted ($serial)" >&2
                echo "$serial"
                return 0
            fi
        fi
        sleep 2
    done
    echo "emu: '$avd' did not finish booting in five minutes; log at $log" >&2
    exit 1
}

cmd_down() {
    if ! serial=$(avd_serial "$adb" "$avd"); then
        echo "emu: '$avd' is not running" >&2
        return 0
    fi
    "$adb" -s "$serial" emu kill >/dev/null
    rm -f "${XDG_RUNTIME_DIR:-/tmp}/emulator-tools/$avd.serial"
    echo "emu: stopped '$avd' ($serial)" >&2
}

# Whether a command that talks to "every attached device" can safely run in
# this directory.
#
# Gradle's Android install, uninstall and connected-test tasks do exactly
# that: they ask the adb server for every device and act on all of them. With
# two sessions' emulators up, `./gradlew installDebug` replaces the app on
# both, reports success, and says nothing -- from the other session it reads
# as its own build never landing. That happened on 2026-08-31.
#
# The rule is the adb wrapper's, so that "which device does a command here
# mean" has one answer: what must not be touched is *another checkout's
# emulator*. A physical phone is nobody's checkout, and this checkout's own
# emulator is this checkout's business.
cmd_check() {
    [ -n "${EMU_ANY_DEVICE:-}" ] && return 0

    others=$(running_avds "$adb" | awk -F'\t' -v mine="$avd" 'NF && $2 != mine')
    [ -n "$others" ] || return 0

    # A caller that named a device has already narrowed the fan-out to one,
    # and choosing a phone or their own emulator is a decision, not a
    # mistake. The single bad case is naming somebody else's -- which is
    # what a serial exported into a long-lived shell decays into, once that
    # emulator restarts and another checkout's takes the port.
    if [ -n "${ANDROID_SERIAL:-}" ]; then
        clash=$(printf '%s\n' "$others" |
            awk -F'\t' -v s="$ANDROID_SERIAL" '$1 == s {print $2; exit}')
        [ -n "$clash" ] || return 0
        {
            echo "emu: ANDROID_SERIAL=$ANDROID_SERIAL is the '$clash' checkout's emulator."
            echo "     This directory means '$avd'."
            echo
            echo "  ANDROID_SERIAL=\$(emu serial) <command>   aim at '$avd' instead"
        } >&2
        exit 1
    fi

    {
        echo "emu: this reaches every attached device, and these belong to other checkouts:"
        printf '%s\n' "$others" | while IFS=$'\t' read -r s n; do
            printf '  %-16s %s\n' "$s" "$n"
        done
        echo
        echo "  ANDROID_SERIAL=\$(emu serial) <command>   aim at this checkout's '$avd'"
        echo "  emu up                                   start '$avd', if it is not running"
        echo "  EMU_ANY_DEVICE=1 <command>               really mean every device"
    } >&2
    exit 1
}

case "${1:-}" in
    name) echo "$avd" ;;
    serial) avd_serial "$adb" "$avd" || { echo "emu: '$avd' is not running" >&2; exit 1; } ;;
    list) cmd_list ;;
    up) cmd_up ;;
    down) cmd_down ;;
    check) cmd_check ;;
    *) usage ;;
esac
