#!/bin/bash
# Keep a local, per-host mirror of the shared bernina pixi python
# environment in sync, so day-to-day python/pixi startup doesn't pay the
# cost of running straight off the shared project share.
#
# Unlike pixi_rebuild_local (which runs `pixi update` to produce a NEW
# lock file for testing), this script mirrors SOURCE_DIR's
# pixi.toml/pixi.lock (+pixi_dev.toml) EXACTLY into LOCAL_DIR and runs
# `pixi install --all` against that lock -- it never regenerates it. That
# keeps every host's local copy identical to whatever was last promoted
# at SOURCE_DIR.
#
# Meant to be launched once, detached, from startup_bernina_terminal (same
# nohup/disown pattern as kerberos_ticket_renew). With no flags it loops
# forever, so a single invocation covers:
#   - first-time install of LOCAL_DIR if it doesn't exist yet
#   - picking up any change to the shared lock file on every terminal
#     restart (the loop's first iteration runs immediately)
#   - noticing further upstream changes while the terminal stays open,
#     checked every CHECK_INTERVAL seconds
#
# Change detection hashes SOURCE_DIR's pixi.toml/pixi.lock/pixi_dev.toml --
# cheap enough to do every minute. The (re)install itself only runs when
# that hash actually changed, or on first install. Before a first install,
# and before any (re)install if free space looks tight, the user is
# asked/warned via a zenity popup so a multi-GB download never happens
# silently without headroom.
#
# Safe to run from multiple terminals on the same host: a flock singleton
# means only one loop actually checks/installs per host at a time.
set -uo pipefail  # no -e: one failed check/install must not kill the loop

SOURCE_DIR="/sf/bernina/applications/python"
PIXI_BIN="/usr/bin/pixi"                                # console (system) pixi first
[ -x "$PIXI_BIN" ] || PIXI_BIN="/sf/bernina/bin/pixi"   # fallback, e.g. on ra (RHEL8)
LOCAL_DIR="${PIXI_LOCAL_DIR:-$HOME/bpy}"
export PIXI_CACHE_DIR="/sf/bernina/misc/pixi-cache"

CHECK_INTERVAL="${PIXI_LOCAL_SYNC_INTERVAL:-60}"          # seconds between checks
EST_INSTALL_GB="${PIXI_LOCAL_SYNC_EST_GB:-15}"             # estimated size of a fresh install
UPDATE_MIN_FREE_GB="${PIXI_LOCAL_SYNC_UPDATE_MIN_GB:-5}"   # floor required for a resync
POPUP_THROTTLE_S=1800                                      # don't repeat resync error popups more often than this
ONCE=0
AUTO_YES=0

usage() {
    cat >&2 <<EOF
Usage: $(basename "$0") [-1] [-y] [-i seconds]

  Mirrors $SOURCE_DIR/pixi.{toml,lock} (+pixi_dev.toml) into
  \$PIXI_LOCAL_DIR (default: \$HOME/bpy) and runs 'pixi install --all'
  there, so this host has its own local copy of the shared python env.

  -1  check/sync once and exit, instead of looping forever
  -y  never prompt before the first install (assume yes)
  -i  seconds between checks when looping (default: 60)
EOF
    exit 1
}

while getopts "1yi:h" opt; do
    case "$opt" in
        1) ONCE=1 ;;
        y) AUTO_YES=1 ;;
        i) CHECK_INTERVAL="$OPTARG" ;;
        h|*) usage ;;
    esac
done

command -v "$PIXI_BIN" >/dev/null 2>&1 || PIXI_BIN="pixi"
command -v "$PIXI_BIN" >/dev/null 2>&1 || { echo "error: pixi not found" >&2; exit 1; }

# Only RHEL9+ consoles are supported: the shared pixi.lock's builds
# aren't verified against RHEL8's older system libraries, and bernina
# consoles are mid-migration from RHEL8 to RHEL9. On anything older,
# clean up any local mirror a pre-this-check attempt may have left
# behind and do nothing else -- no install, no loop.
os_id="" os_major=""
if [ -r /etc/os-release ]; then
    os_id=$(. /etc/os-release 2>/dev/null; echo "$ID")
    os_major=$(. /etc/os-release 2>/dev/null; echo "${VERSION_ID%%.*}")
fi
if [ "$os_id" != "rhel" ] || [ -z "$os_major" ] || [ "$os_major" -lt 9 ] 2>/dev/null; then
    if [ -d "$LOCAL_DIR" ]; then
        echo "$(date '+%F %T') $(hostname) is not RHEL9+ (detected: ${os_id:-unknown} ${os_major:-?}) -- removing local pixi env remnant at $LOCAL_DIR"
        rm -rf "$LOCAL_DIR"
        command -v notify-send >/dev/null 2>&1 && notify-send -u normal "Local python env (bpy)" \
            "Removed $LOCAL_DIR: this host (${os_id:-unknown} ${os_major:-?}) isn't RHEL9+, so it isn't supported." 2>/dev/null
    else
        echo "$(date '+%F %T') $(hostname) is not RHEL9+ (detected: ${os_id:-unknown} ${os_major:-?}) -- nothing to do"
    fi
    exit 0
fi

# Singleton per host+user: startup_bernina_terminal launches this on every
# terminal open, and it's also handy to run by hand -- only one loop
# should ever actually be checking/installing at a time.
LOCKFILE="${XDG_RUNTIME_DIR:-/tmp}/pixi_local_sync.lock"
exec 9>"$LOCKFILE"
flock -n 9 || { echo "already running (lock held: $LOCKFILE)" >&2; exit 0; }

STATE_DIR="$LOCAL_DIR/.pixi_local_sync"

notify() {
    local urgency="$1" title="$2" body="$3"
    echo "$(date '+%F %T') $title: $body"
    command -v notify-send >/dev/null 2>&1 && notify-send -u "$urgency" "$title" "$body" 2>/dev/null
}

have_popup() {
    [ -n "${DISPLAY:-}" ] && command -v zenity >/dev/null 2>&1
}

# free space under $HOME, in whole GB
avail_gb() {
    df -Pk "$HOME" | awk 'NR==2 {printf "%d", $4/1024/1024}'
}

# marker-file throttle: true if $1 was touched less than $2 seconds ago
recently() {
    local marker="$1" within_s="$2" now then
    [ -f "$marker" ] || return 1
    now=$(date +%s)
    then=$(cat "$marker" 2>/dev/null || echo 0)
    [ -n "$then" ] || then=0
    (( now - then < within_s ))
}
mark_now() { date +%s > "$1"; }

# Stop trying a first install for the rest of THIS running instance:
# either the user said no, or there isn't enough space for it right now.
# Popping the same question/warning every minute forever is pointless --
# but deliberately in-memory, not a state file: a fresh invocation of
# this script (next login/terminal, i.e. a new process) should always
# check again from scratch, since free space can change and a decline
# only speaks for the moment it was given, not forever.
GAVE_UP_THIS_RUN=0
give_up() {
    local reason="$1"
    GAVE_UP_THIS_RUN=1
    notify normal "Local python env (bpy)" \
        "Not installing $LOCAL_DIR for now ($reason). Won't ask again this run -- next login/terminal will check again."
}

source_hash() {
    { cat "$SOURCE_DIR/pixi.toml" "$SOURCE_DIR/pixi.lock" 2>/dev/null
      [ -f "$SOURCE_DIR/pixi_dev.toml" ] && cat "$SOURCE_DIR/pixi_dev.toml"
    } | sha256sum | awk '{print $1}'
}

check_space() {
    local need_gb="$1" first_install="$2" have msg
    have=$(avail_gb)
    if [ "$have" -lt "$need_gb" ]; then
        if [ "$first_install" -eq 1 ]; then
            # not enough room for the very first install -- nagging every
            # minute won't change that within the same run, so stop
            # asking for now; a later run (next login) checks fresh.
            if have_popup; then
                printf -v msg 'Not enough free space in $HOME to install the local pixi environment at\n%s\n\nAvailable now:      %s GB\nNeeded (approx.):  %s GB\n\nFree up space; this will be checked again next time a terminal starts.' \
                    "$LOCAL_DIR" "$have" "$need_gb"
                zenity --error --title="Local python env (bpy): not enough space" --text="$msg" 2>/dev/null &
                disown
            fi
            give_up "not enough space at first install: ${have}GB free, ~${need_gb}GB needed"
        elif ! recently "$STATE_DIR/.space_warned" "$POPUP_THROTTLE_S"; then
            notify critical "Local python env (bpy)" \
                "Not enough space in \$HOME to resync $LOCAL_DIR: ${have}GB free, ~${need_gb}GB needed. Free up space -- will retry automatically."
            mkdir -p "$STATE_DIR"
            mark_now "$STATE_DIR/.space_warned"
        fi
        return 1
    fi
    return 0
}

ask_permission() {
    local have projected msg
    have=$(avail_gb)
    projected=$(( have - EST_INSTALL_GB ))
    [ "$projected" -lt 0 ] && projected=0

    [ "$AUTO_YES" -eq 1 ] && return 0

    if ! have_popup; then
        echo "$(date '+%F %T') no graphical session/zenity available -- skipping first install of $LOCAL_DIR for now"
        return 1
    fi

    printf -v msg 'No local copy of the shared bernina python environment was found at:\n%s\n\nInstalling one here speeds up python/pixi startup on this host by\navoiding the network project share.\n\nFree space in $HOME now:      %s GB\nEstimated space needed:      ~%s GB\nProjected free space after:  ~%s GB\n\nInstall it now? (runs in the background, will not block this terminal)' \
        "$LOCAL_DIR" "$have" "$EST_INSTALL_GB" "$projected"

    if zenity --question --title="Set up local python env (bpy)?" --width=420 --text="$msg" 2>/dev/null; then
        return 0
    else
        give_up "declined by user"
        return 1
    fi
}

do_sync() {
    local first_install=0 hash

    [ -d "$LOCAL_DIR/.pixi/envs" ] || first_install=1

    # Already gave up this run (declined, or no space at first install) --
    # stay silent for the rest of this process; a fresh invocation of the
    # script starts with GAVE_UP_THIS_RUN=0 again and checks from scratch.
    if [ "$first_install" -eq 1 ] && [ "$GAVE_UP_THIS_RUN" -eq 1 ]; then
        return
    fi

    hash=$(source_hash)
    [ -n "$hash" ] || { echo "$(date '+%F %T') $SOURCE_DIR not readable, skipping"; return; }

    if [ "$first_install" -eq 0 ] && [ -f "$STATE_DIR/.synced_hash" ] \
        && [ "$(cat "$STATE_DIR/.synced_hash")" = "$hash" ]; then
        return  # nothing changed -- the common case, stay quiet and fast
    fi

    if [ "$first_install" -eq 1 ]; then
        check_space "$EST_INSTALL_GB" 1 || return
        ask_permission || return
        notify normal "Local python env (bpy)" "Installing local copy in $LOCAL_DIR ..."
    else
        check_space "$UPDATE_MIN_FREE_GB" 0 || return
        echo "$(date '+%F %T') shared env changed, resyncing $LOCAL_DIR"
    fi

    mkdir -p "$LOCAL_DIR" "$STATE_DIR"

    if (
        umask 002
        # Exclusive lock for the whole copy+install window: bpy-env.sh takes
        # a non-blocking SHARED lock on this same file before trusting
        # the local mirror, so it falls back to the shared NFS env
        # instead of activating a half-copied/half-linked one. Using
        # flock (not a marker file) means a crash here still releases it
        # automatically -- nothing can get permanently stuck "busy".
        exec 8>"$STATE_DIR/.install.lock"
        flock -x 8
        mkdir -p "$PIXI_CACHE_DIR" 2>/dev/null || true
        chmod 2775 "$PIXI_CACHE_DIR" 2>/dev/null || true
        cp "$SOURCE_DIR/pixi.toml" "$LOCAL_DIR/" || exit 1
        [ -f "$SOURCE_DIR/pixi.lock" ] && cp "$SOURCE_DIR/pixi.lock" "$LOCAL_DIR/"
        [ -f "$SOURCE_DIR/pixi_dev.toml" ] && cp "$SOURCE_DIR/pixi_dev.toml" "$LOCAL_DIR/"
        # --frozen: never let this mirror resolve anything itself (e.g.
        # live git/PyPI access for a dependency the shared pixi.lock
        # hasn't caught up with yet, as happened with the eco package) --
        # only ever install exactly what's already pinned in the lock we
        # just copied. Fails loudly instead of silently drifting if
        # SOURCE_DIR's pixi.toml and pixi.lock are ever inconsistent;
        # fix that with pixi_rebuild_local + promote, not by letting
        # this script improvise.
        cd "$LOCAL_DIR" && "$PIXI_BIN" install --all --frozen
    ); then
        echo "$hash" > "$STATE_DIR/.synced_hash"
        rm -f "$STATE_DIR/.space_warned"
        if [ "$first_install" -eq 1 ]; then
            notify normal "Local python env (bpy)" "Install finished: $LOCAL_DIR is ready ($(avail_gb)GB free in \$HOME now)."
        else
            notify low "Local python env (bpy)" "Resynced $LOCAL_DIR with the latest shared env."
        fi
    else
        notify critical "Local python env (bpy)" "pixi install failed in $LOCAL_DIR -- will retry automatically."
    fi
}

# small jitter on the very first check so a mass terminal restart across
# many hosts doesn't all hit the shared project dir/cache at once
[ "$ONCE" -eq 0 ] && sleep $(( RANDOM % 20 ))

if [ "$ONCE" -eq 1 ]; then
    do_sync
    exit 0
fi

while true; do
    do_sync
    sleep "$CHECK_INTERVAL"
done
