#!/usr/bin/env bash
# ~/.claude config parity: the digest both sides compute, and the probes that prove the
# config is live rather than merely present.
#
# Sourced on the workstation by bin/buildbox (over the staged payload) and shipped to the
# box ahead of lib/buildbox-checks.sh (over the box's real ~/.claude). One implementation,
# two roots — a second copy of this arithmetic is how the two sides start disagreeing
# about what "identical" means.

# The hooks and wrappers below are launched exactly as Claude Code launches them, so they
# need the same PATH a seat gets. A bare ssh command inherits neither the mise shims nor
# ~/.local/bin, and every probe would fail on interpreter resolution rather than on drift.
claude_parity_path() {
  case ":$PATH:" in
    *":$HOME/.local/share/mise/shims:"*) ;;
    *) export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$HOME/.cargo/bin:$PATH" ;;
  esac
}

# Content identity of a set of paths under a root: mode, path, content, and symlink
# targets, order-independent. Symlinks carry meaning here — ~/.claude/bin/bun is a
# relative symlink onto the cpu-guard shim, and a box that materialized it as a plain
# copy would run builds unguarded while every byte-comparison of regular files passed.
claude_home_digest() { # root entry... -> 16 hex chars
  local root="$1"; shift
  {
    cd "$root" 2>/dev/null || { printf 'UNREADABLE-ROOT %s\n' "$root"; return 0; }
    local e
    for e in "$@"; do
      if [ -e "$e" ] || [ -L "$e" ]; then
        # -H follows a symlinked entry itself (a seat-managed entry points into the
        # runtime store), so the digest covers the tree behind it, not the link.
        find -H "$e" \( -type f -o -type l \) -print
      else
        printf 'ABSENT %s\n' "$e"
      fi
    done | LC_ALL=C sort | while IFS= read -r f; do
      case "$f" in ABSENT\ *|UNREADABLE-ROOT\ *) printf '%s\n' "$f"; continue;; esac
      if [ -L "$f" ]; then
        # A symlink at an ENTRY root is the seat-managed seam: Claude reads its
        # content through the link, so equal content means converged no matter
        # which store it resolves into. Symlinks INSIDE an entry keep identity
        # semantics — bin/bun flattened into a copy of the shim must stay drift.
        is_entry=0
        for e in "$@"; do [ "$f" = "$e" ] && { is_entry=1; break; }; done
        t="$(readlink -f "$f" 2>/dev/null)"
        if [ "$is_entry" = 1 ] && [ -n "$t" ] && [ -f "$t" ]; then
          printf '%s %s %s\n' "$(stat -c %a "$t")" "$f" "$(sha256sum "$t" | cut -d' ' -f1)"
        else
          printf 'L %s -> %s\n' "$f" "$(readlink "$f")"
        fi
      else
        printf '%s %s %s\n' "$(stat -c %a "$f")" "$f" "$(sha256sum "$f" | cut -d' ' -f1)"
      fi
    done
  } | sha256sum | cut -c1-16
}

claude_home_entries_from_env() { # -> one entry per line, from CLAUDE_HOME_ENTRIES (base64)
  printf %s "${CLAUDE_HOME_ENTRIES:-}" | base64 -d 2>/dev/null
}

# What this box currently carries, for the workstation to compare before deciding to push.
# A second converge that rewrites an identical tree is not harmful but it is not free
# either: it replaces every hook and skill under whatever seat happens to be running.
claude_home_digest_env() {
  local entries=()
  readarray -t entries < <(claude_home_entries_from_env)
  [ "${#entries[@]}" -gt 0 ] || { echo "no-entries"; return 2; }
  claude_home_digest "$HOME/.claude" "${entries[@]}"
}

claude_home_entry_digests() { # root entry... -> entry<TAB>kind<TAB>digest
  local root="$1" entry kind
  shift
  for entry in "$@"; do
    [ -n "$entry" ] || continue
    if [ -L "$root/$entry" ]; then kind=symlink
    elif [ -d "$root/$entry" ]; then kind=directory
    elif [ -f "$root/$entry" ]; then kind=file
    else kind=absent
    fi
    printf '%s\t%s\t%s\n' "$entry" "$kind" "$(claude_home_digest "$root" "$entry")"
  done
}

claude_home_entry_digests_env() {
  local entries=()
  readarray -t entries < <(claude_home_entries_from_env)
  claude_home_entry_digests "$HOME/.claude" "${entries[@]}"
}

# --- behavioral probes -------------------------------------------------------
# File presence and matching hashes prove a copy arrived. They do not prove Claude Code
# on this box would actually refuse a forbidden read, or that a wrapper on the PATH it
# builds resolves to the shim rather than the real binary. Each probe below runs the
# shipped artifact and asserts on its observable effect.

# The wrapper deny-gate, driven with the payload Claude Code hands it. Chosen over the
# other hooks because its verdict depends on nothing machine-local: it reads the shipped
# tools.json registry and the shipped hook, so a correct verdict here proves two declared
# entries agree with each other and that the hook's interpreter resolves on this box. The
# probes that looked more dramatic (the sudo-password gate) turn on a file that exists
# only on the workstation and would report a false failure on every box forever.
claude_probe_deny_gate() { # -> prints "ok <detail>" / "fail <detail>"; returns 0/1
  local hook="$HOME/.claude/hooks/deny-gate.mjs" out
  claude_parity_path
  [ -x "$hook" ] || { echo "fail deny-gate.mjs absent or not executable"; return 1; }

  out=$(printf '{"tool_name":"Bash","tool_input":{"command":"ccr start parity-probe"}}' | "$hook" 2>&1)
  case "$out" in
    *'"permissionDecision":"deny"'*) ;;
    "") echo "fail deny-gate allowed a command tools.json denies (ccr-start-raw)"; return 1;;
    *) echo "fail deny-gate returned no deny verdict: ${out:0:100}"; return 1;;
  esac

  # A gate that denies everything is equally broken and equally invisible to a hash.
  out=$(printf '{"tool_name":"Bash","tool_input":{"command":"echo parity-probe"}}' | "$hook" 2>&1)
  [ -z "$out" ] || { echo "fail deny-gate blocked an ordinary command: ${out:0:100}"; return 1; }

  echo "ok deny-gate deny+allow both correct against shipped tools.json"
}

# ~/.claude/bin is a directory of wrappers, several of them relative symlinks onto the
# cpu-guard shim. The assertion is the resolved target and a working exec, because a
# wrapper that resolves to the real binary offloads nothing and answers --version happily.
#
# The interpreter *version* is deliberately not an assertion here: the fleet declares only
# BUN_MAJOR in devtools.json, so a box on a different patch release is a passing box by the
# toolchain's own contract. Failing config parity on an undeclared patch skew would flap a
# gate the seat stack blocks launches on. The version is reported, not enforced; toolchain
# version parity belongs to item_bun.
claude_probe_wrapper() { # workstation-bun-version (reported only) -> "ok …"/"fail …"
  local want="$1" link bin="$HOME/.claude/bin/bun" got note=""
  claude_parity_path
  [ -L "$bin" ] || { echo "fail ~/.claude/bin/bun is not a symlink onto the cpu-guard shim"; return 1; }
  link=$(readlink "$bin")
  case "$link" in *_cpu-guard-shim.sh) ;; *) echo "fail ~/.claude/bin/bun -> $link, want the cpu-guard shim"; return 1;; esac
  got=$("$bin" --version 2>/dev/null | tr -d '\r\n')
  [ -n "$got" ] || { echo "fail ~/.claude/bin/bun does not execute"; return 1; }
  [ -z "$want" ] || [ "$got" = "$want" ] || note=" (workstation $want; see item_bun)"
  echo "ok bun $got via $link$note"
}

# --- audit items -------------------------------------------------------------
# ok/bad/fixed come from lib/buildbox-checks.sh when this file is sourced ahead of it.

item_claude_home() {
  local have entries=()
  if [ -z "${EXPECT_CLAUDE_HOME:-}" ]; then
    bad claude-home "expected digest not supplied (run via bin/buildbox, not by hand)"
    return
  fi
  readarray -t entries < <(claude_home_entries_from_env)
  if [ "${#entries[@]}" -eq 0 ]; then
    bad claude-home "entry list not supplied (run via bin/buildbox, not by hand)"
    return
  fi
  have=$(claude_home_digest "$HOME/.claude" "${entries[@]}")
  if [ "$have" = "$EXPECT_CLAUDE_HOME" ]; then
    ok claude-home "$have (${#entries[@]} declared entries)"
  else
    bad claude-home "box $have want $EXPECT_CLAUDE_HOME (fix: buildbox bootstrap)"
  fi
}

# Claude Code starts a hook whose command is missing and carries on without a word, so a
# stale registration is invisible at the only moment it matters. The workstation cannot
# see this box's ~/.dev-tools when it stages the payload; this is where a rewritten path
# that did not land is caught.
# A hook that exists but starts a missing interpreter is as silent as a missing hook, so the
# registration alone is not enough. Only command position counts — an absolute path after
# `exec`, `timeout N`, a pipe/`;`/`&&`, or `$(` — because a guarded fallback candidate
# (`[ -x /usr/bin/bun ]`) is not a dependency of the host that reads it.
# A path counts only when it starts at a real filesystem root and carries no regex
# metacharacter: a hook body is full of `/^[A-Za-z_]/` and `s|x|y|`, which sit in the same
# command position as a path and would otherwise flood the audit with drift that is not.
hook_script_commands() { # hook-script -> one absolute command path per line
  local roots='(usr|bin|sbin|opt|home|etc|var|srv|lib)'
  head -c 65536 "$1" 2>/dev/null \
    | grep -oE "(^|[;&|]|\\\$\\(|exec |timeout [0-9]+[smhd]? )[[:space:]]*/$roots/[A-Za-z0-9_./+-]+" \
    | grep -oE "/$roots/[A-Za-z0-9_./+-]+" \
    | sort -u
}

item_claude_hooks() {
  local settings="$HOME/.claude/settings.json" command tok bare missing=()
  local -a tokens
  [ -r "$settings" ] || { bad claude-hooks "no ~/.claude/settings.json (fix: buildbox bootstrap)"; return; }
  while IFS= read -r command; do
    read -r -a tokens <<<"$command"
    for tok in "${tokens[@]}"; do
      bare="${tok#[\"\']}"; bare="${bare%[\"\']}"
      case "$bare" in /*) ;; *) continue ;; esac
      if [ -e "$bare" ]; then
        while IFS= read -r dep; do
          [ -e "$dep" ] || missing+=("$bare -> $dep")
        done < <(hook_script_commands "$bare")
      else
        missing+=("$bare")
      fi
    done
  done < <(jq -r '.. | objects | select(.type? == "command" and (.command? | type == "string")) | .command' "$settings")
  if [ "${#missing[@]}" -eq 0 ]; then
    ok claude-hooks "every registered hook path present"
  else
    bad claude-hooks "$(printf '%s ' "${missing[@]}")— registered but absent, Claude skips these silently (fix: buildbox bootstrap)"
  fi
}

item_claude_behavior() {
  local out
  out=$(claude_probe_deny_gate) && ok claude-hook "$out" || bad claude-hook "${out#fail } (fix: buildbox bootstrap)"
  out=$(claude_probe_wrapper "${EXPECT_BUN_VERSION:-}") && ok claude-wrapper "$out" || bad claude-wrapper "${out#fail } (fix: buildbox bootstrap)"
}

# --- one-line parity verdict -------------------------------------------------
# The seat stack gates a launch on this: one line, one exit code, no door probes and no
# devtools checks. Deliberately says nothing about credentials — convergence ships config
# only, and the seat stack still owns auth.
claude_parity_report() { # -> one line on stdout, exit 0 only when everything holds
  local have entries=() detail="" rc=0 out
  readarray -t entries < <(claude_home_entries_from_env)
  if [ -z "${EXPECT_CLAUDE_HOME:-}" ] || [ "${#entries[@]}" -eq 0 ]; then
    echo "error expectation not supplied (run via bin/buildbox, not by hand)"
    return 2
  fi
  have=$(claude_home_digest "$HOME/.claude" "${entries[@]}")
  if [ "$have" != "$EXPECT_CLAUDE_HOME" ]; then
    detail="$detail config=$have/want=$EXPECT_CLAUDE_HOME"; rc=1
  fi
  out=$(claude_probe_deny_gate) || { detail="$detail hook=${out#fail }"; rc=1; }
  out=$(claude_probe_wrapper "${EXPECT_BUN_VERSION:-}") || { detail="$detail wrapper=${out#fail }"; rc=1; }
  if [ "$rc" = 0 ]; then
    echo "converged $have config-only (credentials not distributed)"
  else
    echo "drifted$detail"
  fi
  return "$rc"
}
