#!/usr/bin/env bash
# buildbox-parity.sh — keep the fleet's ~/.claude config converged, and record the
# per-host verdict for the deck.
#
# Run by systemd-user timer (buildbox-parity.timer). Every land moves the expected
# digest, so drift is the normal steady state between runs; this converges it and only
# speaks when converging did not work.
#
# Anti-spam: one message per crossing into drift. A host that has already been announced
# never announces again until it comes back converged. `unreachable` carries no
# information about config and never changes what has been announced.
set -uo pipefail

SELF="$(readlink -f "${BASH_SOURCE[0]}")"
REPO="${SELF%/modules/workstation/claude/bin/*}"
BUILDBOX="${BUILDBOX_BIN:-$REPO/modules/buildbox/bin/buildbox}"
STATE_DIR="${PARITY_STATE_DIR:-$HOME/.claude/run/buildbox-parity}"
STATE_FILE="$STATE_DIR/state.json"
LOG_FILE="${PARITY_LOG_FILE:-$HOME/.claude/buildbox-parity.log}"
NOTIFY_CMD="${PARITY_NOTIFY_CMD:-notify-send}"

mkdir -p "$STATE_DIR"

# A bootstrap pass outlives the timer interval, and two of them interleave their pushes
# into the same ~/.claude on the same box.
exec 9>"$STATE_DIR/converge.lock"
flock -n 9 || exit 0

log() { printf '%s %s\n' "$(date -Is)" "$*" >>"$LOG_FILE"; }

declare -A VERDICT DETAIL
SEEN=0

# The box-side digest out of `drifted config=<hex>/want=<hex>`; empty when the host drifted
# for a behavioral reason instead, or is not drifted at all.
config_digest() { # detail -> hex
  local d="${1#*config=}"
  [ "$d" != "$1" ] || return 0
  printf '%s' "${d%%/*}"
}

# `buildbox claude-parity` prints one line per host, host first. Its exit code cannot tell
# drifted from unreachable — both are 1 — so the verdict comes from the line.
read_verdicts() {
  local host rest
  while read -r host rest; do
    [ -n "$host" ] || continue
    SEEN=$((SEEN + 1))
    case "$rest" in
      converged*)   VERDICT["$host"]=converged ;;
      drifted*)     VERDICT["$host"]=drifted ;;
      unreachable*) VERDICT["$host"]=unreachable ;;
      *)            VERDICT["$host"]=error ;;
    esac
    DETAIL["$host"]="$rest"
  done
}

read_verdicts < <("$BUILDBOX" claude-parity 2>>"$LOG_FILE")

if [ "$SEEN" -eq 0 ]; then
  log "probe produced no host lines — leaving the recorded state untouched"
  exit 3
fi

readarray -t HOSTS < <(printf '%s\n' "${!VERDICT[@]}" | LC_ALL=C sort)

converge_at=""
drifted=()
for h in "${HOSTS[@]}"; do
  [ "${VERDICT[$h]}" = drifted ] && drifted+=("$h")
done

# Converge before judging: a host that drifts on every land is not an incident, it is the
# thing this unit exists to fix. Only a host still drifted after converging is news.
# The seat provisioner converges FIRST: it updates ~/.claude through the managed
# runtime store, so a healthy seat stays a seat. The tar push of real files is the
# fallback for a box the provisioner cannot repair — it destroys seat links, which
# the next provision run then restores.
declare -A PROGRESS
if [ "${#drifted[@]}" -gt 0 ]; then
  declare -A BEFORE
  for h in "${drifted[@]}"; do BEFORE["$h"]="$(config_digest "${DETAIL[$h]}")"; done
  converge_at="$(date -Is)"
  PROVISION_BIN="${PARITY_PROVISION_BIN:-$HOME/.claude/bin/provision-remote-seats}"
  if [ -x "$PROVISION_BIN" ]; then
    log "provision ${drifted[*]}: start"
    # The provisioner validates its cwd as a git project root; the timer unit
    # starts in $HOME, so run it from the same landed checkout this script
    # resolves everything else against.
    provision_rc=0
    (cd "$REPO" && "$PROVISION_BIN" "${drifted[@]}") >>"$LOG_FILE" 2>&1 || provision_rc=$?
    log "provision ${drifted[*]}: rc=$provision_rc"
    if [ "$provision_rc" -eq 0 ]; then
      for h in "${drifted[@]}"; do unset 'VERDICT[$h]' 'DETAIL[$h]'; done
      read_verdicts < <("$BUILDBOX" claude-parity "${drifted[@]}" 2>>"$LOG_FILE")
      still=()
      for h in "${drifted[@]}"; do
        [ "${VERDICT[$h]:-}" = drifted ] && still+=("$h")
      done
      drifted=("${still[@]}")
    else
      # Provision failure means the managed-runtime repair path itself was not
      # trustworthy (for example a dirty deploy source). Falling back to the
      # tar bootstrap here destroys healthy seat symlinks and turns a source
      # integrity refusal into fleet-wide runtime loss. Preserve the original
      # drift verdict, notify, and leave the seat layout untouched.
      log "provision failed; refusing destructive bootstrap fallback"
      drifted=()
    fi
  fi
fi
if [ "${#drifted[@]}" -gt 0 ]; then
  log "bootstrap ${drifted[*]}: start"
  "$BUILDBOX" bootstrap "${drifted[@]}" >>"$LOG_FILE" 2>&1
  log "bootstrap ${drifted[*]}: rc=$?"
  for h in "${drifted[@]}"; do unset 'VERDICT[$h]' 'DETAIL[$h]'; done
  read_verdicts < <("$BUILDBOX" claude-parity "${drifted[@]}" 2>>"$LOG_FILE")
  for h in "${drifted[@]}"; do
    [ -n "${VERDICT[$h]:-}" ] || { VERDICT["$h"]=error; DETAIL["$h"]="no verdict after bootstrap"; }
    after="$(config_digest "${DETAIL[$h]}")"
    if [ -n "${BEFORE[$h]}" ] && [ -n "$after" ] && [ "$after" != "${BEFORE[$h]}" ]; then
      PROGRESS["$h"]=1
    fi
  done
fi

probed_at="$(date -Is)"
announce=()
notify=()
for h in "${HOSTS[@]}"; do
  prev="$(jq -r --arg h "$h" '.announced[$h] // ""' "$STATE_FILE" 2>/dev/null)"
  case "${VERDICT[$h]}" in
    converged)   tier=ok ;;
    unreachable) tier="$prev" ;;
    # The box took the push and the workstation's expectation moved again underneath it —
    # another land, not a broken convergence. Recorded as drifted, never announced.
    *)           if [ -n "${PROGRESS[$h]:-}" ]; then tier="$prev"; else tier=bad; fi ;;
  esac
  if [ "$tier" = bad ] && [ "$prev" != bad ]; then notify+=("$h"); fi
  if [ "$tier" != "$prev" ]; then log "$h ${prev:-unknown} -> ${tier:-unknown} (${DETAIL[$h]})"; fi
  announce+=("$h	$tier")
done

{
  for h in "${HOSTS[@]}"; do
    printf 'host\t%s\t%s\t%s\n' "$h" "${VERDICT[$h]}" "${DETAIL[$h]}"
  done
  printf 'announced\t%s\n' "${announce[@]}"
} | jq -Rn --arg probedAt "$probed_at" --arg convergedAt "$converge_at" '
  [inputs | split("\t")] as $rows
  | {
      schemaVersion: 1,
      probedAt: $probedAt,
      lastConvergeAt: (if $convergedAt == "" then null else $convergedAt end),
      hosts: ($rows | map(select(.[0] == "host"))
              | map({ key: .[1], value: { verdict: .[2], detail: .[3], probedAt: $probedAt } })
              | from_entries),
      announced: ($rows | map(select(.[0] == "announced") | select(.[2] != ""))
              | map({ key: .[1], value: .[2] }) | from_entries),
    }' >"$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"

if [ "${#notify[@]}" -gt 0 ]; then
  "$NOTIFY_CMD" -u normal -i network-error "Buildbox config drift" \
    "${notify[*]} still drifted after buildbox bootstrap — see $LOG_FILE" 2>/dev/null || true
fi
