#!/usr/bin/bash
# smilebasic-sync -- two-way sync between a host folder and SmileBASIC's LIVE
# workspace: the vfat loop image mounted inside the running container, so what
# this copies in shows up in SmileBASIC's FILES straight away (no restart, no
# image rebuild), and what SmileBASIC saves comes back out.
#
#   smilebasic-sync fish             two-way sync ./fish <-> your programs
#   smilebasic-sync fish --all       include SmileBASIC's stock IMAGE/SOUND/... too
#   smilebasic-sync fish -n          dry run -- show what would move, touch nothing
#
# By default the five stock top-level folders (IMAGE PROJECT SOUND SYSTEM TEMP)
# are skipped, so a sync only moves YOUR programs and does not drag SmileBASIC's
# bundled assets into your host folder (or back).  --all disables that filter.
#
# Rules:
#   - on one side only        -> copied to the other
#   - on both, same contents  -> left alone
#   - on both, DIFFERENT      -> conflict: you are asked to keep the host copy or
#                               take the container's.  Nothing is overwritten
#                               without an answer.
# Deletions are never propagated: a two-way sync with no memory of the last run
# cannot tell "deleted over there" from "new over here", and guessing wrong
# silently destroys work.  Remove files by hand on both sides.
#
# NOTE: deliberately NOT `set -o pipefail`.  This tool inspects commands that
# exit non-zero on purpose, and pipefail turning `cmd | grep` into the *command's*
# status (not grep's) is exactly the bug that made smilebasic-launcher warn
# "virgl too old" on every single launch while running hardware anyway.
set -u

CONTAINER="${SB_CONTAINER:-sbqemu}"
WS="${SB_WORKSPACE:-/armhf/boot/SMILEBOOM/SMILEBASIC-R/workspace}"

msg() { printf 'smilebasic-sync: %s\n' "$*"; }
err() { printf 'smilebasic-sync: %s\n' "$*" >&2; }
die() { err "$*"; exit 1; }

usage() {
    cat >&2 <<EOF
usage: smilebasic-sync <folder> [--all] [-n|--dry-run]

Two-way sync between <folder> on this machine and your SmileBASIC programs.
Files only on one side are copied across; identical files are skipped; files
that differ prompt you to keep the host copy or take the container's.

  --all         also sync SmileBASIC's stock top-level folders
                (IMAGE PROJECT SOUND SYSTEM TEMP); by default they are skipped
  -n            dry run: print the plan, change nothing

env: SB_CONTAINER (default: sbqemu), SB_WORKSPACE (default: the mounted vfat)
EOF
    exit 2
}

# SmileBASIC's own top-level folders -- skipped unless --all, so a plain sync
# moves only the user's programs.
STOCK_DIRS="IMAGE PROJECT SOUND SYSTEM TEMP"

HOSTDIR=""; ALL=0; DRY=0
while [ $# -gt 0 ]; do
    case "$1" in
        --all) ALL=1; shift ;;
        -n|--dry-run) DRY=1; shift ;;
        -h|--help) usage ;;
        -*) err "unknown option: $1"; usage ;;
        *) [ -n "$HOSTDIR" ] && { err "only one folder may be given"; usage; }
           HOSTDIR="$1"; shift ;;
    esac
done
[ -n "$HOSTDIR" ] || usage

# Build the find prune expression for the stock dirs (top level only), unless --all.
PRUNE=""
if [ "$ALL" = 0 ]; then
    for d in $STOCK_DIRS; do PRUNE="$PRUNE -path ./$d -prune -o"; done
fi

command -v docker >/dev/null 2>&1 || die "docker not found."

# The workspace only exists while the container is up AND setup.sh has loop-
# mounted the vfat over it; a stopped container would silently sync nothing.
running="$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)"
[ "$running" = "true" ] || die "container '$CONTAINER' is not running -- start SmileBASIC first (smilebasic-launcher)."
docker exec "$CONTAINER" test -d "$WS" 2>/dev/null \
    || die "workspace '$WS' not present in '$CONTAINER' (is the vfat mounted? try restarting SmileBASIC)."

if [ ! -d "$HOSTDIR" ]; then
    msg "creating '$HOSTDIR'"
    [ "$DRY" = 1 ] || mkdir -p "$HOSTDIR" || die "cannot create '$HOSTDIR'"
fi
HOSTDIR="$(cd "$HOSTDIR" 2>/dev/null && pwd)" || die "cannot enter '$HOSTDIR'"

# ---- inventory both sides: md5sum prints "<32 hex>  ./<relative path>" ------
# The union is derived from the raw listings with sed rather than by iterating
# the arrays' keys: `${!A[@]}` cannot be combined with a `+default` guard (bash
# reads it as indirect expansion and dies "invalid variable name"), and `${#A[@]}`
# on an EMPTY associative array still trips `set -u` here.  Cutting the fixed-width
# hash off the listing is simpler and keeps filenames with spaces intact.
# $PRUNE (when set) skips the stock top-level dirs: `-path ./IMAGE -prune -o ...`.
# The `-o` makes md5sum run only on the surviving `-type f` branch.  With --all
# it is empty and every file is listed.
HOST_RAW="$(cd "$HOSTDIR" && find . $PRUNE -type f -exec md5sum {} + 2>/dev/null)"
CONT_RAW="$(docker exec "$CONTAINER" sh -c "cd '$WS' && find . $PRUNE -type f -exec md5sum {} + 2>/dev/null")"

declare -A HOST CONT
while read -r h p; do [ -n "${h:-}" ] || continue; HOST["${p#./}"]="$h"; done <<< "$HOST_RAW"
while read -r h p; do [ -n "${h:-}" ] || continue; CONT["${p#./}"]="$h"; done <<< "$CONT_RAW"

paths="$( { printf '%s\n' "$HOST_RAW"; printf '%s\n' "$CONT_RAW"; } \
          | sed -n 's/^[0-9a-f]\{32\}  \.\///p' | sort -u)"
[ -n "$paths" ] || { msg "both sides are empty -- nothing to do."; exit 0; }

# ---- conflict prompt --------------------------------------------------------
# Open the terminal on fd 3 so prompts work even when stdout is redirected.
# `[ -r /dev/tty ]` is NOT a usable test: the node exists and is readable-looking
# even with no controlling terminal, and the open then fails at prompt time.
# Try the open once, up front, and fall back to skipping.
# (braces matter: `exec 3<>/dev/tty 2>/dev/null` applies the redirections in
# order, so the tty open fails and prints BEFORE stderr is silenced.)
if { exec 3<>/dev/tty; } 2>/dev/null; then HAVE_TTY=1; else HAVE_TTY=0; fi

# resolve() reports through the global CHOICE rather than stdout, because the
# caller would otherwise have to run it as `$(resolve ...)` -- a SUBSHELL, where
# the ALL=keep/take assignment for "apply to all" is discarded on return, so K/T
# would silently re-prompt on every single conflict.
ALL=""      # set to keep/take once the user answers K/T
CHOICE=""   # resolve()'s answer for the current file
resolve() {
    local rel="$1" hs cs hm cm ans
    [ -n "$ALL" ] && { CHOICE="$ALL"; return; }
    if [ "$DRY" = 1 ]; then        # dry run: report, never ask
        CHOICE=dry; return
    fi
    if [ "$HAVE_TTY" = 0 ]; then   # non-interactive: never guess
        CHOICE=skip; return
    fi
    hs="$(stat -c%s "$HOSTDIR/$rel" 2>/dev/null || echo ?)"
    hm="$(stat -c%y "$HOSTDIR/$rel" 2>/dev/null | cut -d. -f1 || echo ?)"
    cs="$(docker exec "$CONTAINER" stat -c%s "$WS/$rel" 2>/dev/null || echo ?)"
    cm="$(docker exec "$CONTAINER" stat -c%y "$WS/$rel" 2>/dev/null | cut -d. -f1 || echo ?)"
    {
        printf '\nCONFLICT  %s  (same name, different contents)\n' "$rel"
        printf '   host       %10s B   %s\n' "$hs" "$hm"
        printf '   container  %10s B   %s\n' "$cs" "$cm"
        printf '   [k] keep host copy      [t] take container copy   [s] skip\n'
        printf '   [K] keep host for ALL   [T] take container for ALL\n'
    } >&3
    while :; do
        printf '   choice [k/t/s/K/T]: ' >&3
        read -r ans <&3 || { CHOICE=skip; return; }
        case "$ans" in
            k) CHOICE=keep; return ;;
            t) CHOICE=take; return ;;
            s|"") CHOICE=skip; return ;;
            K) ALL=keep; CHOICE=keep; return ;;
            T) ALL=take; CHOICE=take; return ;;
            *) printf '   answer k, t, s, K or T\n' >&3 ;;
        esac
    done
}

# Transfers go through `docker exec ... tar`, NEVER `docker cp`.
#
# `docker cp` works on the container's rootfs as the daemon knows it and does not
# enter the container's mount namespace -- so it cannot see the vfat that setup.sh
# loop-mounts over SMILEBASIC-R.  That fails in the worst possible way: a download
# errors with "Could not find the file", but an upload SILENTLY SUCCEEDS into the
# directory *underneath* the mount, where SmileBASIC can never see it and where a
# later image flatten would bake it in.  `docker exec` runs inside the namespace
# and sees the real vfat, so tar over a pipe is the only correct transport.
# --no-same-owner/--no-same-permissions are REQUIRED, not tidiness: the workspace
# is vfat, which has no ownership, so tar's chown returns EPERM and tar exits 2
# ("Cannot change ownership to uid ...") even though the file extracted fine --
# reporting a bogus failure for every upload.
upload() {   # host -> container
    [ "$DRY" = 1 ] && return 0
    local d; d="$(dirname "$1")"
    [ "$d" = "." ] || docker exec "$CONTAINER" mkdir -p "$WS/$d" || return 1
    tar -C "$HOSTDIR" -cf - -- "$1" 2>/dev/null \
        | docker exec -i "$CONTAINER" tar -C "$WS" -xf - --no-same-owner --no-same-permissions
}
download() { # container -> host
    [ "$DRY" = 1 ] && return 0
    local d; d="$(dirname "$1")"
    [ "$d" = "." ] || mkdir -p "$HOSTDIR/$d" || return 1
    docker exec "$CONTAINER" tar -C "$WS" -cf - -- "$1" 2>/dev/null \
        | tar -C "$HOSTDIR" -xf - --no-same-owner
}

up=0; down=0; same=0; skipped=0; failed=0
while IFS= read -r rel; do
    [ -n "$rel" ] || continue
    h="${HOST[$rel]:-}"; c="${CONT[$rel]:-}"
    if [ -n "$h" ] && [ -z "$c" ]; then
        msg "up     $rel"
        if upload "$rel"; then up=$((up+1)); else err "FAILED up   $rel"; failed=$((failed+1)); fi
    elif [ -z "$h" ] && [ -n "$c" ]; then
        msg "down   $rel"
        if download "$rel"; then down=$((down+1)); else err "FAILED down $rel"; failed=$((failed+1)); fi
    elif [ "$h" = "$c" ]; then
        same=$((same+1))
    else
        resolve "$rel"          # sets CHOICE (must NOT be a subshell -- see above)
        case "$CHOICE" in
            keep) msg "up     $rel  (kept host copy)"
                  if upload "$rel"; then up=$((up+1)); else err "FAILED up   $rel"; failed=$((failed+1)); fi ;;
            take) msg "down   $rel  (took container copy)"
                  if download "$rel"; then down=$((down+1)); else err "FAILED down $rel"; failed=$((failed+1)); fi ;;
            dry)  msg "CONFLICT $rel  (differs -- would ask keep/take)"; skipped=$((skipped+1)) ;;
            *)    msg "skip   $rel  (left both alone)"; skipped=$((skipped+1)) ;;
        esac
    fi
done <<< "$paths"

# vfat writes sit in the page cache; flush so SmileBASIC (and the .img) see them.
[ "$DRY" = 1 ] || docker exec "$CONTAINER" sync 2>/dev/null || true

msg "---"
[ "$DRY" = 1 ] && msg "DRY RUN -- nothing was changed"
msg "uploaded $up, downloaded $down, identical $same, skipped $skipped, failed $failed"
[ "$failed" -eq 0 ] || exit 1
exit 0
