#!/usr/bin/env bash
#
# marmot-interop.sh — interop test harness: Amethyst <-> MDK (wn/wnd)
#
# Sequential, all-or-nothing. Script drives the `wn` side automatically and
# prompts the human operator at each step that requires Amethyst UI action.
#
# Usage: ./marmot-interop.sh [--public-relays] [--port N] [--transponder] [--no-build]
#
# By default the harness boots its own relay — `amy serve`, i.e. geode — bound
# to 0.0.0.0:$RELAY_PORT so the wn daemons reach it on loopback and the phone
# reaches it over the LAN (ws://<laptop-ip>:PORT, or ws://10.0.2.2:PORT from an
# emulator). Pass --public-relays to run the old real-world path against the
# public relay set instead; that is the only mode that touches the internet.
#
set -uo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state"
LOG_DIR="$STATE_DIR/logs"
B_DIR="$STATE_DIR/B"
C_DIR="$STATE_DIR/C"
# The harness pins the daemon socket explicitly via wnd's --socket flag, so
# these paths are our choice rather than a guess at wnd's derived default.
B_SOCKET="$B_DIR/wnd.sock"
C_SOCKET="$C_DIR/wnd.sock"

RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"

WN_REPO="${WN_REPO:-$STATE_DIR/mdk}"
WN_BIN=""
WND_BIN=""
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"

# Embedded relay (default mode). Bound on every interface so a device on the
# same network can reach it; the daemons connect over $RELAY_HOST. Loopback
# `ws://` relays are only accepted by MDK behind this explicit opt-in.
#
# Known limit, inherited from the old --local-relays mode: the URL wn
# advertises in its kind:10050/10051 lists is $RELAY_URL, and Amethyst's
# parsers drop loopback and RFC1918 relays from those lists, so A→B welcome
# delivery leans on Amethyst's fallback relays. Override RELAY_HOST with an
# address the device can dial (e.g. the laptop's LAN IP) to have wn
# advertise that instead; the daemons then connect to it too.
RELAY_HOST="${RELAY_HOST:-127.0.0.1}"
RELAY_BIND="${RELAY_BIND:-0.0.0.0}"
RELAY_PORT="${RELAY_PORT:-8080}"
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
RELAY_DATA="$STATE_DIR/relay"
export WN_ALLOW_LOOPBACK_RELAYS=1
B_NPUB=""
B_HEX=""
C_NPUB=""
C_HEX=""
A_NPUB=""
A_HEX=""

# Only used with --public-relays.
DEFAULT_RELAYS=(
  "wss://relay.damus.io"
  "wss://nos.lol"
  "wss://relay.primal.net"
  "wss://nostr.bitcoiner.social"
  "wss://nostr.mom"
)
USE_PUBLIC_RELAYS=0
ENABLE_TRANSPONDER=0
NO_BUILD=0

usage() {
  cat <<EOF
marmot-interop.sh — Amethyst <-> MDK interop harness

Options:
  --public-relays   Use the public relay set instead of the embedded relay
                    (amy serve / geode, the default). Only mode that leaves the machine.
  --port N          Port for the embedded relay (default 8080)
  --transponder     Run Test 14 (MIP-05 push notifications)
  --no-build        Don't rebuild wn/wnd if binaries are missing
  -h, --help        Show this help

Environment:
  WN_REPO           Path to the mdk checkout (default: state/mdk)
EOF
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --public-relays) USE_PUBLIC_RELAYS=1 ;;
    --local-relays)  printf '%s\n' "note: --local-relays is now the default (embedded amy serve relay); flag ignored" >&2 ;;
    --port)
      [[ $# -ge 2 && "$2" != --* ]] || { printf 'missing value for --port\n' >&2; usage; exit 2; }
      RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
    --transponder)   ENABLE_TRANSPONDER=1 ;;
    --no-build)     NO_BUILD=1 ;;
    -h|--help)      usage; exit 0 ;;
    *) printf 'unknown flag: %s\n' "$1" >&2; usage; exit 2 ;;
  esac
  shift
done

mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs"
: >"$LOG_FILE"
: >"$RESULTS_FILE"

# shellcheck source=../lib.sh
source "$TESTS_DIR/lib.sh"
# shellcheck source=../headless/helpers.sh — start_local_relay / stop_local_relay (embedded amy serve)
source "$TESTS_DIR/headless/helpers.sh"
B_SOCKET="$(short_socket_path "$B_SOCKET")"
C_SOCKET="$(short_socket_path "$C_SOCKET")"

# --- preflight ---------------------------------------------------------------
preflight() {
  banner "Preflight"
  for cmd in jq git curl cargo; do
    if ! command -v "$cmd" >/dev/null 2>&1; then
      fail_msg "missing required tool: $cmd"; exit 1
    fi
    printf '  %s: %s\n' "$cmd" "$(command -v "$cmd")" >>"$LOG_FILE"
  done

  # The embedded relay is `amy serve`, so amy has to exist unless the run
  # goes to the public relays. Same transient-503 retry as the headless
  # harness: one bad jitpack/dl.google.com roll must not abort the run.
  if [[ "$USE_PUBLIC_RELAYS" -ne 1 && ! -x "$AMY_BIN" ]]; then
    if [[ "$NO_BUILD" -eq 1 ]]; then
      fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1
    fi
    local attempt max_attempts=4
    for attempt in $(seq 1 $max_attempts); do
      step "building :cli:installDist (attempt $attempt/$max_attempts)"
      if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \
          && [[ -x "$AMY_BIN" ]]; then
        break
      fi
      [[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying"
    done
    [[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
    printf '  amy: %s\n' "$AMY_BIN" >>"$LOG_FILE"
  fi

  WN_BIN="$WN_REPO/target/release/wn"
  WND_BIN="$WN_REPO/target/release/wnd"

  if [[ ! -x "$WN_BIN" || ! -x "$WND_BIN" ]]; then
    if [[ "$NO_BUILD" -eq 1 ]]; then
      fail_msg "wn/wnd not found and --no-build set: $WN_BIN"; exit 1
    fi
    if [[ ! -d "$WN_REPO/.git" ]]; then
      # marmot-protocol/whitenoise-rs was archived on 2026-08-05; wn/wnd now
      # ship from marmot-protocol/mdk as the `wn-cli` package.
      step "cloning mdk into $WN_REPO"
      git clone --depth 1 https://github.com/marmot-protocol/mdk.git "$WN_REPO" \
        2>&1 | tee -a "$LOG_FILE"
    fi
    step "building wn + wnd (cargo build --release -p wn-cli) — ~5 min first run"
    ( cd "$WN_REPO" && cargo build --release -p wn-cli --bin wn --bin wnd ) \
      2>&1 | tee -a "$LOG_FILE"
  fi
  printf '  wn:  %s\n  wnd: %s\n' "$WN_BIN" "$WND_BIN" >>"$LOG_FILE"
}

# --- daemons -----------------------------------------------------------------
# One attempt at bringing wnd up. Returns 0 on ready, 1 on crash/timeout.
# Caller inspects $data_dir/logs/stderr.log to decide whether to retry.
_start_daemon_attempt() {
  local name="$1" data_dir="$2" socket="$3"
  rm -f "$socket"
  mkdir -p "$data_dir/logs"
  # MDK's wnd accepts an explicit --socket, so the harness pins the listen
  # path instead of guessing at the derived one ({home}/dev/wnd.sock today).
  # --secret-store file keeps account secrets out of the OS keychain, which
  # is what lets this run in a container.
  nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \
    --socket "$socket" --secret-store file \
    >"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" &
  local pid=$!
  echo "$pid" > "$data_dir/pid"
  info "$name pid $pid; waiting for socket at $socket ..."
  local deadline=$(( $(date +%s) + 30 ))
  while [[ $(date +%s) -lt $deadline ]]; do
    if [[ -S "$socket" ]] && "$WN_BIN" --socket "$socket" whoami >/dev/null 2>&1; then
      info "$name ready"
      return 0
    fi
    # Exit early if wnd already crashed — no point waiting the full 30s.
    if ! kill -0 "$pid" 2>/dev/null; then
      return 1
    fi
    sleep 1
  done
  # Hung rather than crashed — kill it so the retry path has a clean slot.
  if kill -0 "$pid" 2>/dev/null; then
    kill "$pid" 2>/dev/null || true
    sleep 1
    kill -9 "$pid" 2>/dev/null || true
  fi
  return 1
}

start_daemon() {
  local name="$1" data_dir="$2" socket="$3"
  step "starting $name daemon"
  if [[ -S "$socket" ]] && "$WN_BIN" --socket "$socket" whoami >/dev/null 2>&1; then
    info "$name daemon already running"
    return 0
  fi
  if _start_daemon_attempt "$name" "$data_dir" "$socket"; then
    return 0
  fi
  # Recover from a stale MLS SQLite DB whose secret has gone missing (the
  # data dir was restored without its secret store, or an earlier run used a
  # different --secret-store). wnd can't open the DB in that state, but the
  # identity is disposable — wipe the data dir and let ensure_identity
  # recreate it.
  if [[ -s "$data_dir/logs/stderr.log" ]] && \
     grep -q 'KeyringEntryMissingForExistingDatabase' "$data_dir/logs/stderr.log"; then
    warn "$name: stale MLS DB detected (keyring entry missing) — wiping $data_dir and retrying"
    find "$data_dir" -mindepth 1 -maxdepth 1 ! -name 'logs' \
      -exec rm -rf {} + 2>/dev/null || true
    if _start_daemon_attempt "$name" "$data_dir" "$socket"; then
      return 0
    fi
  fi
  fail_msg "$name daemon failed to start"
  if [[ -s "$data_dir/logs/stderr.log" ]]; then
    printf '  --- last 40 lines of %s ---\n' "$data_dir/logs/stderr.log" >&2
    tail -n 40 "$data_dir/logs/stderr.log" | sed 's/^/  /' >&2
    printf '  --- end stderr ---\n' >&2
  fi
  if [[ -s "$data_dir/logs/stdout.log" ]]; then
    printf '  --- last 20 lines of %s ---\n' "$data_dir/logs/stdout.log" >&2
    tail -n 20 "$data_dir/logs/stdout.log" | sed 's/^/  /' >&2
    printf '  --- end stdout ---\n' >&2
  fi
  exit 1
}

stop_daemons() {
  for d in "$B_DIR" "$C_DIR"; do
    if [[ -f "$d/pid" ]]; then
      local pid; pid=$(cat "$d/pid" 2>/dev/null || echo "")
      if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
        info "stopping daemon pid $pid"
        kill "$pid" 2>/dev/null || true
        sleep 1
        kill -9 "$pid" 2>/dev/null || true
      fi
      rm -f "$d/pid"
    fi
  done
}

# --- identities --------------------------------------------------------------
ensure_identity() {
  local who="$1" cmd
  if [[ "$who" == "B" ]]; then cmd=wn_b; else cmd=wn_c; fi
  step "ensuring Identity $who"

  # First try whoami (will be empty if no account yet). Try --json, then the
  # yaml-ish pretty form as a fallback.
  local out npub=""
  out=$("$cmd" --json whoami 2>/dev/null || true)
  npub=$(extract_pubkey "$out")
  if [[ -z "${npub:-}" ]]; then
    out=$("$cmd" whoami 2>/dev/null || true)
    npub=$(extract_pubkey "$out")
  fi

  if [[ -z "${npub:-}" ]]; then
    info "creating new identity for $who"
    out=$("$cmd" create-identity 2>&1 | tee -a "$LOG_FILE")
    npub=$(extract_pubkey "$out")
    # Some builds only print the npub on whoami after create, not on
    # create-identity itself — re-query as a last resort.
    if [[ -z "${npub:-}" ]]; then
      out=$("$cmd" whoami 2>/dev/null || true)
      npub=$(extract_pubkey "$out")
    fi
  fi

  if [[ -z "${npub:-}" ]]; then
    fail_msg "could not determine npub for $who — raw output below:"
    printf '%s\n' "$out" | tee -a "$LOG_FILE" >&2
    exit 1
  fi

  local hex
  hex=$(npub_to_hex "$npub")
  if [[ "$who" == "B" ]]; then B_NPUB="$npub"; B_HEX="$hex"
  else                         C_NPUB="$npub"; C_HEX="$hex"; fi
  save_state "${who}_NPUB" "$npub"
  save_state "${who}_HEX"  "$hex"
  info "$who npub: $npub"
  [[ "$hex" != "$npub" ]] && info "$who hex:  $hex"
}

prompt_for_a_npub() {
  A_NPUB=$(load_state "A_NPUB" || true)
  if [[ -n "${A_NPUB:-}" ]]; then
    info "A npub (cached): $A_NPUB"
  else
    printf '\n%sPaste your Amethyst account npub:%s ' "$C_CYAN" "$C_RESET" >&2
    read -r A_NPUB
    save_state "A_NPUB" "$A_NPUB"
  fi
  A_HEX=$(npub_to_hex "$A_NPUB")
  save_state "A_HEX" "$A_HEX"
  info "A npub: $A_NPUB"
  [[ "$A_HEX" != "$A_NPUB" ]] && info "A hex:  $A_HEX"
}

# Trigger wn's on-demand discovery for A and dump what wn actually
# learned — kinds 10002 / 10050 / 10051 from A's configured relays.
# Called after prompt_for_a_npub so wn's SQLite cache is populated
# BEFORE the test suite starts, and so the operator can spot "wn sees
# nothing for A" failures up front instead of as a cryptic Test 03
# timeout.
#
# Implementation:
#  - `wn keys check <npub>` calls `resolve_user_blocking` which issues
#    a targeted fetch for kinds [0, 10002, 10050, 10051] on the
#    discovery-plane relays. That's the only published side-effect
#    way to populate wn's user_relays table (no public CLI reads
#    another user's relay lists directly).
#  - After the fetch, `sqlite3` is used to SELECT from `user_relays`
#    joined against `users` + `relays`. If sqlite3 isn't installed
#    the probe degrades to "trust it, go run the tests" with a warning.
discover_a_relays() {
  banner "Discovering A's advertised relays via wn"
  step "wn_b keys check \$A_NPUB — populates wn's user_relays cache for A"
  local kp_raw kp_event_id
  kp_raw=$(wn_b --json keys check "$A_NPUB" 2>>"$LOG_FILE" || true)
  printf 'wn_b keys check A raw: %s\n' "$kp_raw" >>"$LOG_FILE"
  kp_event_id=$(printf '%s' "$kp_raw" | jq -r '.result.event_id // .event_id // empty' 2>/dev/null || true)
  if [[ -n "$kp_event_id" && "$kp_event_id" != "null" ]]; then
    info "wn_b found A's KeyPackage (kind:30443) — discovery plane is working"
  else
    warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${RELAY_LIST[*]}."
    warn "Either Amethyst never published a KeyPackage, or it's only on relays wn can't reach."
    warn "All later tests will fail. Fix this before continuing (tap KP publish in Amethyst settings)."
  fi
  # Also prime wn_c so Tests 04+ (which use C as a third member) can
  # target A without another round-trip later.
  wn_c --json keys check "$A_NPUB" >>"$LOG_FILE" 2>&1 || true

  # Give wn ~3s to process the kind:10002/10050/10051 events that
  # came back in the same fetch (they're not event_id-returned, they
  # just land in user_relays via the subscription callback).
  sleep 3

  if ! command -v sqlite3 >/dev/null 2>&1; then
    warn "sqlite3 not installed — skipping wn user_relays cache probe."
    warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun without --public-relays."
    return
  fi

  # wn stores its database under $data_dir/release/<something>.sqlite.
  # Find the DB file by looking for the only *.sqlite under release/.
  local db
  db=$(find "$B_DIR/release" -maxdepth 1 -name '*.sqlite' -print -quit 2>/dev/null || true)
  if [[ -z "$db" || ! -s "$db" ]]; then
    warn "wn SQLite DB not found under $B_DIR/release — cannot probe user_relays."
    return
  fi

  # Screen: one-line summary (counts per list). Full rows to $LOG_FILE.
  local rows
  # wn's SQLite schema stores `users.pubkey` — observed as both BLOB
  # (raw 32 bytes) and TEXT (lowercase hex) across versions. Try both
  # forms so the probe doesn't falsely report "NO cached relay entries"
  # when the cache is actually populated.
  rows=$(sqlite3 -readonly "$db" \
    "SELECT ur.relay_type, r.url FROM user_relays ur
       JOIN users u ON u.id = ur.user_id
       JOIN relays r ON r.id = ur.relay_id
      WHERE u.pubkey = X'$A_HEX'
         OR LOWER(CAST(u.pubkey AS TEXT)) = LOWER('$A_HEX')
      ORDER BY ur.relay_type, r.url;" 2>>"$LOG_FILE" || true)
  if [[ -z "$rows" ]]; then
    warn "wn has NO cached relay entries for A ($A_HEX)."
    warn "  → Either Amethyst hasn't published kind:10002/10050/10051 to any relay wn_b"
    warn "    can reach, OR wn's SQLite schema changed pubkey column encoding again."
    return
  fi

  # Collate by relay_type. Full lists go to $LOG_FILE, only a count
  # summary reaches stderr so the happy path stays quiet.
  local nip65 inbox kp
  nip65=$(printf '%s\n' "$rows" | awk -F'|' '$1=="nip65"{print $2}')
  inbox=$(printf '%s\n' "$rows" | awk -F'|' '$1=="inbox"{print $2}')
  kp=$(printf '%s\n' "$rows" | awk -F'|' '$1=="key_package"{print $2}')
  {
    printf "A's kind:10002 (nip65):\n%s\n" "${nip65:-  <none>}"
    printf "A's kind:10050 (DM inbox):\n%s\n" "${inbox:-  <none>}"
    printf "A's kind:10051 (KeyPackage relays):\n%s\n" "${kp:-  <none>}"
  } >>"$LOG_FILE"
  local n_nip65 n_inbox n_kp
  n_nip65=$(printf '%s' "$nip65" | grep -c '.' || true)
  n_inbox=$(printf '%s' "$inbox" | grep -c '.' || true)
  n_kp=$(printf '%s' "$kp" | grep -c '.' || true)
  info "A's relay lists: nip65=$n_nip65, inbox=$n_inbox, key_package=$n_kp (full lists in $LOG_FILE)"

  # Warn loudly if A's DM inbox is non-empty but shares no relay with
  # wn_b's own configured set — that's the exact failure mode the
  # operator already hit ("my DM inbox has auth.nostr1.com, wn can't
  # publish there") and it silently breaks Test 03+.
  if [[ -n "$inbox" ]]; then
    local wn_relays
    wn_relays=$(wn_b --json relays list 2>/dev/null \
      | jq -r '(.result // .)[]? | (.url // .relay.url // empty)' 2>/dev/null)
    local overlap=""
    while IFS= read -r a_url; do
      a_url="${a_url#    }"
      [[ -z "$a_url" ]] && continue
      if printf '%s\n' "$wn_relays" | grep -qxF "$a_url"; then
        overlap+="$a_url,"
      fi
    done <<< "$inbox"
    overlap="${overlap%,}"
    if [[ -z "$overlap" ]]; then
      warn "A's DM inbox (kind:10050) shares ZERO relays with wn_b's bootstrap set."
      warn "  wn may still reach A's 10050 relays if they're public — but if any require"
      warn "  NIP-42 AUTH (auth.nostr1.com) or a whitelist (relay.0xchat.com), the welcome"
      warn "  gift wrap silently fails to publish."
      warn "  Real-world fix: edit Amethyst's DM Inbox list to include at least one of:"
      warn "    ${DEFAULT_RELAYS[*]}"
    else
      info "A's DM inbox overlaps wn's bootstrap at: $overlap — welcomes should flow"
    fi
  fi
}

# --- relays ------------------------------------------------------------------
configure_relays() {
  banner "Configuring relays"
  local relays=( "${RELAY_LIST[@]}" )
  # Each relay × 3 types × 2 daemons produces a lot of repetitive "ok"
  # lines — the happy path doesn't need any of it on screen. Quiet the
  # per-add logging into $LOG_FILE and only surface real failures as
  # warnings. A single summary line prints at the end.
  local adds_ok=0 adds_dup=0 adds_fail=0
  add_relay() {
    local wn_fn="$1" r="$2" t add_out
    for t in nip65 inbox key_package; do
      if add_out=$("$wn_fn" relays add --type "$t" "$r" 2>&1); then
        adds_ok=$((adds_ok+1))
        printf '%s relays add --type %s %s: ok\n' "$wn_fn" "$t" "$r" >>"$LOG_FILE"
      else
        case "$add_out" in
          *"already exists"*|*"already added"*|*"duplicate"*)
            adds_dup=$((adds_dup+1))
            printf '%s relays add --type %s %s: already exists\n' "$wn_fn" "$t" "$r" >>"$LOG_FILE" ;;
          *)
            adds_fail=$((adds_fail+1))
            warn "$wn_fn relays add --type $t $r failed: $add_out"
            printf '%s relays add --type %s %s: %s\n' "$wn_fn" "$t" "$r" "$add_out" >>"$LOG_FILE" ;;
        esac
      fi
    done
  }

  step "adding ${#relays[@]} relay(s) × 3 types × 2 daemons"
  for r in "${relays[@]}"; do
    add_relay wn_b "$r"
    add_relay wn_c "$r"
  done
  info "relay add: $adds_ok ok, $adds_dup already-present, $adds_fail failed"

  # Push a kind:0 profile for each wn identity BEFORE the kind:30443 /
  # 10050 / 1059 probes. Several public relays (damus.io in particular)
  # silently drop non-profile kinds from accounts they've never seen
  # before, so a fresh wn identity created inside the harness never gets
  # its gift wraps or inbox lists stored. Publishing kind:0 first
  # registers the account in the relay's "known users" set, which lets
  # every later publish go through.
  publish_profile() {
    local who="$1" wnfn
    if [[ "$who" == "B" ]]; then wnfn=wn_b; else wnfn=wn_c; fi
    local name="marmot-interop $who"
    local about="Scripted wn identity for Amethyst<->MDK interop harness"
    local out
    if out=$("$wnfn" profile update --name "$name" --about "$about" 2>&1); then
      printf '%s profile update ok: %s\n' "$who" "$out" >>"$LOG_FILE"
    else
      warn "$who profile update failed: $out"
      printf '%s profile update: %s\n' "$who" "$out" >>"$LOG_FILE"
    fi
  }
  step "publishing kind:0 profiles so relays treat B and C as 'known' accounts"
  publish_profile B
  publish_profile C
  # Give the relay a beat to persist + ack the kind:0 before the next
  # publish (some relays gate subsequent writes on the profile being
  # indexed, and without this pause the first `keys publish` that
  # follows sometimes races ahead of the kind:0 commit).
  sleep 2

  # Full relay list goes to $LOG_FILE only — the per-relay status rarely
  # matters unless a later test fails, and it's 5-10 noisy lines otherwise.
  local relay_list
  relay_list=$(wn_b --json relays list 2>/dev/null || true)
  printf 'B relay list: %s\n' "$relay_list" >>"$LOG_FILE"

  # Sanity check is split across three relay-level surfaces so a failure
  # points at the exact kind the relay set can't handle, instead of 10
  # later tests all timing out with the same "no invite" symptom:
  #
  #   kind:30443 (KeyPackage)        — both sides publish + discover
  #   kind:10050 (Inbox advertise)   — C must be reachable as a giftwrap target
  #   kind:1059  (Gift wrap)         — B->C welcome delivery
  #   kind:445   (MLS commit/msg)    — real group message round-trip
  #
  # All failures are warnings (not hard fails) so a known-broken relay set
  # still lets the harness continue into the 13 tests — they will fail
  # anyway but at least the operator will have the diagnostic hint here.
  step "sanity-check kind:30443: B+C publish + symmetric discover"
  local publish_out
  if ! publish_out=$(wn_b keys publish 2>&1); then
    warn "wn_b keys publish failed: $publish_out"
  fi
  printf 'wn_b keys publish: %s\n' "$publish_out" >>"$LOG_FILE"
  if ! publish_out=$(wn_c keys publish 2>&1); then
    warn "wn_c keys publish failed: $publish_out"
  fi
  printf 'wn_c keys publish: %s\n' "$publish_out" >>"$LOG_FILE"
  sleep 4
  local sanity_raw sanity_id sanity_cb="" sanity_bc=""
  sanity_raw=$(wn_c --json keys check "$B_NPUB" 2>>"$LOG_FILE" || true)
  sanity_id=$(printf '%s' "$sanity_raw" | jq -r '.result.event_id // .event_id // empty' 2>/dev/null || true)
  printf 'sanity keys check B raw: %s\n' "$sanity_raw" >>"$LOG_FILE"
  if [[ -n "$sanity_id" && "$sanity_id" != "null" ]]; then
    sanity_cb="$sanity_id"
  else
    warn "kind:30443 C->B failed — relays may be rejecting KeyPackages"
  fi
  sanity_raw=$(wn_b --json keys check "$C_NPUB" 2>>"$LOG_FILE" || true)
  sanity_id=$(printf '%s' "$sanity_raw" | jq -r '.result.event_id // .event_id // empty' 2>/dev/null || true)
  printf 'sanity keys check C raw: %s\n' "$sanity_raw" >>"$LOG_FILE"
  if [[ -n "$sanity_id" && "$sanity_id" != "null" ]]; then
    sanity_bc="$sanity_id"
  else
    warn "kind:30443 B->C failed — relays may be rejecting KeyPackages"
  fi
  if [[ -n "$sanity_cb" && -n "$sanity_bc" ]]; then
    info "kind:30443 B<->C ok (C->B=${sanity_cb:0:8}… B->C=${sanity_bc:0:8}…)"
  fi

  step "sanity-check kinds 10050/1059/445: B creates group + invites C + exchanges a message"
  local sanity_gid sanity_c_gid sanity_create c_ignore
  # Snapshot C's pending invites before we publish, so wait_for_invite
  # doesn't fire on a stale welcome left over from a previous run (wnd
  # persists pending invites across restarts — we saw this cause the
  # kind:445 sanity probe to send to B's new group while C was still
  # sitting on an unaccepted welcome from a previous group that B
  # wasn't a member of anymore).
  c_ignore=$(snapshot_invites C)
  [[ -n "$c_ignore" ]] && info "C already has stale invites, ignoring: $c_ignore"
  sanity_create=$(wn_b --json groups create "sanity-check" "$C_NPUB" 2>>"$LOG_FILE" || true)
  sanity_gid=$(printf '%s' "$sanity_create" | jq_group_id)
  printf 'sanity groups create raw: %s\n' "$sanity_create" >>"$LOG_FILE"
  if [[ -z "$sanity_gid" ]]; then
    warn "sanity group create failed — see $LOG_FILE (stale account state? rerun with 'rm -rf state/')"
  else
    printf 'sanity group id: %s\n' "$sanity_gid" >>"$LOG_FILE"
    if sanity_c_gid=$(wait_for_invite C 30 "$c_ignore"); then
      if [[ "$sanity_c_gid" != "$sanity_gid" ]]; then
        warn "gid mismatch — C saw $sanity_c_gid, B created $sanity_gid (likely a stale welcome slipped past the filter)"
      fi
      wn_c groups accept "$sanity_c_gid" >/dev/null 2>&1 || true
      wn_b messages send "$sanity_gid" "sanity-ping" >/dev/null 2>&1 || true
      if wait_for_message C "$sanity_c_gid" "sanity-ping" 30; then
        info "sanity kinds 10050/1059/445 ok (B->C welcome + message round-trip)"
      else
        warn "kind:445 failed — C never decrypted sanity-ping (relays may be dropping group messages)"
        warn "Consider rerunning without --public-relays (the embedded relay stores every kind)."
      fi
      # best-effort cleanup so re-runs don't accumulate dead sanity groups
      wn_c groups leave "$sanity_c_gid" >/dev/null 2>&1 || true
      wn_b groups leave "$sanity_gid" >/dev/null 2>&1 || true
    else
      warn "kind:10050/1059 failed — C never received welcome; relays likely dropping gift wraps or inbox lists"
      warn "Consider rerunning without --public-relays (the embedded relay stores every kind)."
    fi
  fi
}

instruct_amethyst_setup() {
  if [[ "$USE_PUBLIC_RELAYS" -ne 1 ]]; then
    # Offline/sandbox path (default): we own the only relay — the embedded
    # `amy serve` (geode) on 0.0.0.0:$RELAY_PORT — so the harness DOES need
    # to dictate Amethyst's relay config; nothing is discoverable via the
    # public network.
    prompt_human "Configure Amethyst to use this harness's embedded relay (amy serve / geode):
  1. Settings -> Relays:  add as READ+WRITE
       ws://10.0.2.2:$RELAY_PORT     (Android emulator)
       ws://<your-LAN-ip>:$RELAY_PORT  (physical device on same Wi-Fi)
  2. Settings -> Key Package Relays:  add the SAME URL
  3. Settings -> DM Inbox Relays (NIP-17/kind:10050):  add the SAME URL
  4. Trigger key-package publish (toggle KP relay on/off if needed)
  5. Confirm your Amethyst account is logged in with npub: $A_NPUB"
    return
  fi

  # --public-relays path: the harness should behave like any real Nostr
  # client — discover A's advertised relays via kind:10002 / 10050 /
  # 10051 and publish there, rather than forcing A to adopt the
  # harness's own relay set. That lets the tests surface real-world
  # interop failures (e.g. A's DM inbox points at auth-required or
  # unreachable relays) instead of hiding them behind shared config.
  prompt_human "No relay reconfiguration required. Just confirm:
  1. Amethyst is logged in as npub:
       $A_NPUB
  2. Amethyst has published (on its own chosen relays):
       - kind:30443 KeyPackage
       - kind:10051 Key Package Relay List
       - kind:10050 DM Inbox Relay List
       - kind:10002 NIP-65 Outbox/Inbox Relay List
     Any one of Amethyst's public write relays will do — the wn daemons
     below are bootstrapped on $(printf '%s, ' "${DEFAULT_RELAYS[@]}" | sed 's/, $//') and will
     discover A's advertised relays automatically.
  3. If the wn-side diagnostic after this prompt shows that wn cannot
     see any of A's four lists, Amethyst hasn't published them to any
     relay wn can reach — republish them (toggle a relay off/on in
     Settings, then verify via an external relay inspector)."
}

# ==== tests ==================================================================

test_01_keypackage_discovery() {
  banner "Test 01 — KeyPackage publish & discovery (MIP-00)"

  step "B fetches A's KeyPackage from relays"
  local kp_relays kp_raw kp_event_id
  kp_relays=$(wn_b --json relays list 2>/dev/null || wn_b relays list 2>/dev/null || true)
  printf 'B relay list: %s\n' "$kp_relays" >>"$LOG_FILE"

  kp_raw=$(wn_b --json keys check "$A_NPUB" 2>>"$LOG_FILE" || true)
  printf 'wn keys check raw output: %s\n' "$kp_raw" >>"$LOG_FILE"
  kp_event_id=$(printf '%s' "$kp_raw" | jq -r '.result.event_id // .event_id // empty' 2>/dev/null || true)

  if [[ -n "$kp_event_id" && "$kp_event_id" != "null" ]]; then
    pass_msg "B discovered A's KeyPackage (event_id: $kp_event_id)"
    record_result "01a KeyPackage A->B" pass
  else
    fail_msg "B cannot find A's KeyPackage. Did Amethyst publish to the same relays?"
    printf 'raw response from wn keys check: %s\n' "$kp_raw" >>"$LOG_FILE"
    record_result "01a KeyPackage A->B" fail "wn keys check returned no event_id"
  fi

  prompt_human "In Amethyst: open 'Create Group' (or 'Add Member' on an existing group).
  Type or paste: $B_NPUB
  Amethyst should resolve the npub and enable 'Add' (KeyPackage discovered)."
  if confirm "Does Amethyst resolve $B_NPUB as having a valid KeyPackage?"; then
    record_result "01b KeyPackage B->A" pass
  else
    record_result "01b KeyPackage B->A" fail "Amethyst did not resolve B"
  fi
}

test_02_amethyst_creates_group() {
  banner "Test 02 — Amethyst creates group, invites B"

  # Snapshot B's already-pending invites so we can tell the new welcome
  # apart from a leftover from a previous run. Baseline daemon dump is
  # skipped on the happy path — dumped on timeout below instead.
  local b_ignore
  b_ignore=$(snapshot_invites B)
  [[ -n "$b_ignore" ]] && info "B already has stale invites, ignoring: $b_ignore"

  prompt_human "In Amethyst:
  1. Tap '+' -> Create Group
  2. Name: Interop-02
  3. Add member: $B_NPUB
  4. Tap Create / Send Invite

Tip: watch the Android logcat for tag 'MarmotDbg' — you should see
'publishing welcome gift wrap id=… kind:1059 → N relay(s): [...]' listing
the same relays as B's inbox above. If the list is empty or different,
that's the bug."

  step "polling B's daemon for invite (60s, heartbeat every ~10s)"
  local gid
  if ! gid=$(wait_for_invite B 60 "$b_ignore"); then
    fail_msg "no invite arrived at B"
    dump_daemon_diagnostics B "post-timeout (test_02)"
    prompt_amethyst_logcat "test_02 timeout"
    record_result "02 Amethyst->B create+invite" fail "invite never arrived"
    return
  fi
  save_state "GROUP_02" "$gid"
  info "group_id: $gid"

  step "B accepts invite"
  if ! wn_b groups accept "$gid" >/dev/null 2>&1; then
    fail_msg "B failed to accept invite"
    record_result "02 Amethyst->B create+invite" fail "accept failed"
    return
  fi

  step "verifying member list on B"
  local members
  members=$(wn_b --json groups members "$gid" 2>/dev/null | jq -r '(.result // .) | .[].pubkey // .[].public_key' 2>/dev/null | sort | tr '\n' ' ')
  if ! expect_contains "$members" "$A_HEX" "member list" \
     || ! expect_contains "$members" "$B_HEX" "member list"; then
      record_result "02 Amethyst->B create+invite" fail "member list mismatch"
      return
  fi

  prompt_human "In Amethyst, send this message in group 'Interop-02':
    hello from amethyst"

  step "B waits for the message (30s)"
  if wait_for_message B "$gid" "hello from amethyst" 30; then
    pass_msg "B decrypted message from Amethyst"
  else
    fail_msg "B never saw 'hello from amethyst'"
    record_result "02 Amethyst->B create+invite" fail "inbound message missing"
    return
  fi

  step "B sends reply"
  local send_out
  if ! send_out=$(wn_b messages send "$gid" "hello from wn" 2>&1); then
    warn "wn messages send returned nonzero: $send_out"
  fi
  printf 'wn messages send (test_02 reply) raw: %s\n' "$send_out" >>"$LOG_FILE"
  # Give the relay a couple seconds to fan the commit/message back out to
  # Amethyst's subscription before we ask the operator to eyeball the UI.
  sleep 4

  if confirm "Does Amethyst now show 'hello from wn' in the same group?"; then
    record_result "02 Amethyst->B create+invite" pass
  else
    # Dump the B side so the failure report shows whether the send reached
    # B's own DB and which relays wn tagged on the kind:445, then ask for
    # the Amethyst side so the report has both halves of the pipe.
    dump_outbound_diagnostics B "$gid" "hello from wn" "test_02 B->A reply"
    prompt_human "Capture Amethyst's side of the B->A kind:445 path. On the adb host:

    adb logcat -d -v time | grep -E \
        'MarmotDbg|GroupEventHandler|MarmotGroupEventsEoseManager|kind:445' \\
        | tail -n 200 > /tmp/amethyst-marmot.log

Then paste /tmp/amethyst-marmot.log here. Key lines to look for:
  - 'MarmotGroupEventsEoseManager.updateFilter: group=${gid:0:8}…' + the relay
    list — must match the 'group relays (MLS extension)' in the B-side dump.
  - 'GroupEventHandler.add: kind:445 id=… groupId=${gid:0:8}…' (event arrived).
  - 'GroupEventHandler.add: not a member of group=${gid:0:8}…' (dropped).
  - 'GroupEventHandler.add: processGroupEvent returned ApplicationMessage…' (decrypted)."
    record_result "02 Amethyst->B create+invite" fail "Amethyst did not display reply"
  fi
}

test_03_wn_creates_group() {
  banner "Test 03 — B creates group, invites Amethyst"

  step "B creates group 'Interop-03' with A as sole invitee"
  local out gid
  out=$(wn_b --json groups create "Interop-03" "$A_NPUB" 2>>"$LOG_FILE")
  printf 'wn groups create raw output: %s\n' "$out" >>"$LOG_FILE"
  gid=$(printf '%s' "$out" | jq_group_id)
  if [[ -z "$gid" ]]; then
    fail_msg "could not parse group_id from 'wn groups create' output"
    info "raw output: $out"
    record_result "03 B->Amethyst create+invite" fail "no group_id"
    return
  fi
  save_state "GROUP_03" "$gid"
  info "group_id: $gid"

  prompt_human "In Amethyst, you should see a new pending group invite 'Interop-03' from $B_NPUB.
  Accept the invite and open the chat."
  if ! confirm "Did Amethyst accept and open group 'Interop-03'?"; then
    record_result "03 B->Amethyst create+invite" fail "Amethyst did not accept invite"
    return
  fi

  step "B sends: 'ping from wn'"
  wn_b messages send "$gid" "ping from wn" >/dev/null 2>&1 || true

  if ! confirm "Does Amethyst show 'ping from wn'?"; then
    record_result "03 B->Amethyst create+invite" fail "Amethyst did not show wn message"
    return
  fi

  prompt_human "In Amethyst, send: 'pong from amethyst'"
  if wait_for_message B "$gid" "pong from amethyst" 30; then
    record_result "03 B->Amethyst create+invite" pass
  else
    record_result "03 B->Amethyst create+invite" fail "B did not receive pong"
  fi
}

test_04_three_member_group() {
  banner "Test 04 — 3-member group, add C after create"

  step "C publishes KeyPackage so A can discover it"
  wn_c keys publish >/dev/null 2>&1 || true
  sleep 4

  local gid
  gid=$(load_state GROUP_02 || true)
  if [[ -z "${gid:-}" ]]; then
    warn "GROUP_02 not set (Test 02 likely skipped/failed); creating new group here"
    local out
    out=$(wn_b --json groups create "Interop-04-bootstrap" "$A_NPUB" 2>>"$LOG_FILE")
    gid=$(printf '%s' "$out" | jq_group_id)
    save_state GROUP_02 "$gid"
    prompt_human "In Amethyst, accept the new group invite 'Interop-04-bootstrap'."
  fi
  info "using group $gid"

  # Baseline C-side daemon dump is skipped on the happy path; the
  # invite-timeout branch below still dumps on failure.
  local c_ignore
  c_ignore=$(snapshot_invites C)
  [[ -n "$c_ignore" ]] && info "C already has stale invites, ignoring: $c_ignore"

  prompt_human "In Amethyst, open the group from Test 02 (or 'Interop-04-bootstrap').
  Group Info -> Add Member -> paste: $C_NPUB
  Confirm the invite is sent."

  step "polling C for invite (60s, heartbeat every ~10s)"
  local c_gid
  if ! c_gid=$(wait_for_invite C 60 "$c_ignore"); then
    fail_msg "C never received invite"
    dump_daemon_diagnostics C "post-timeout (test_04)"
    prompt_amethyst_logcat "test_04 timeout"
    record_result "04 3-member add-after-create" fail "invite to C missing"
    return
  fi
  wn_c groups accept "$c_gid" >/dev/null 2>&1 || true
  info "C accepted invite ($c_gid)"

  step "verifying all three members on all three clients"
  local members_b
  members_b=$(wn_b --json groups members "$gid" 2>/dev/null | jq -r '(.result // .) | .[].pubkey // .[].public_key' | tr '\n' ' ')
  expect_contains "$members_b" "$A_HEX" "B's member list" || :
  expect_contains "$members_b" "$B_HEX" "B's member list" || :
  expect_contains "$members_b" "$C_HEX" "B's member list" || :

  prompt_human "In Amethyst, send: 'hello three-member world'"

  if wait_for_message B "$gid" "hello three-member world" 30 \
     && wait_for_message C "$c_gid" "hello three-member world" 30; then
    pass_msg "both B and C decrypted Amethyst's message after Add"
    record_result "04 3-member add-after-create" pass
  else
    fail_msg "B or C did not see Amethyst's post-Add message"
    record_result "04 3-member add-after-create" fail "message not received after Add"
  fi
}

test_05_wn_adds_amethyst_existing() {
  banner "Test 05 — wn adds Amethyst to existing B+C group"

  step "B creates group with only C, then adds A"
  local out gid c_ignore
  c_ignore=$(snapshot_invites C)
  out=$(wn_b --json groups create "Interop-05" "$C_NPUB" 2>>"$LOG_FILE")
  printf 'wn groups create raw output: %s\n' "$out" >>"$LOG_FILE"
  gid=$(printf '%s' "$out" | jq_group_id)
  if [[ -z "$gid" ]]; then
    fail_msg "could not create Interop-05"
    record_result "05 wn adds Amethyst existing" fail "create failed"
    return
  fi
  save_state GROUP_05 "$gid"
  local c_new_gid
  if c_new_gid=$(wait_for_invite C 30 "$c_ignore"); then
    wn_c groups accept "$c_new_gid" >/dev/null 2>&1 || true
  fi

  step "B adds A to the group"
  wn_b groups add-members "$gid" "$A_NPUB" >/dev/null 2>&1 || {
    fail_msg "wn groups add-members failed"
    record_result "05 wn adds Amethyst existing" fail "add-members failed"
    return
  }

  prompt_human "In Amethyst, you should see a new invite 'Interop-05'.
  Accept and open the chat."

  prompt_human "In Amethyst, send: 'joined from amethyst'"
  if wait_for_message B "$gid" "joined from amethyst" 30 \
     && wait_for_message C "$gid" "joined from amethyst" 30; then
    record_result "05 wn adds Amethyst existing" pass
  else
    record_result "05 wn adds Amethyst existing" fail "message not seen by B or C"
  fi
}

test_06_member_removal() {
  banner "Test 06 — Member removal, forward secrecy at next epoch"

  local gid
  gid=$(load_state GROUP_05 || true)
  if [[ -z "${gid:-}" ]]; then
    skip_msg "Test 06 requires Test 05 state"; record_result "06 member removal" skip "no GROUP_05"; return
  fi

  # Interop-05 was created by B (wn), so B is the sole admin. Per
  # MIP-03 `enforceAuthorizedProposalSet`, non-admin members may only
  # commit a self-Update or a SelfRemove-only proposal set — a Remove
  # proposal from A (a plain member here) is rejected before publish
  # with "non-admin members may only commit …". The previous revision
  # of this test asked the operator to remove C from Amethyst's UI,
  # which is correct per spec to REJECT — so the test itself was
  # misaligned with the admin model. Drive the removal from B (the
  # admin) instead, and observe its effect on A + C.
  step "B (admin) removes C from Interop-05"
  local remove_out
  if ! remove_out=$(wn_b groups remove-members "$gid" "$C_NPUB" 2>&1); then
    fail_msg "wn_b groups remove-members failed: $remove_out"
    printf 'wn_b groups remove-members: %s\n' "$remove_out" >>"$LOG_FILE"
    record_result "06 member removal" fail "wn remove returned nonzero"
    return
  fi
  printf 'wn_b groups remove-members: %s\n' "$remove_out" >>"$LOG_FILE"

  step "verifying C is removed from B's + C's view (60s poll)"
  local deadline=$(( $(date +%s) + 60 )) removed_b=0 removed_c=0
  while [[ $(date +%s) -lt $deadline ]]; do
    if ! wn_b --json groups members "$gid" 2>/dev/null \
         | jq -e --arg p "$C_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' >/dev/null 2>&1; then
      removed_b=1
    fi
    if ! wn_c --json groups members "$gid" 2>/dev/null \
         | jq -e --arg p "$C_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' >/dev/null 2>&1; then
      removed_c=1
    fi
    [[ "$removed_b" -eq 1 ]] && break
    sleep 3
  done
  if [[ "$removed_b" -ne 1 ]]; then
    warn "B still shows C as a member after remove — commit may not have applied"
  fi
  info "post-remove: B sees C gone=$removed_b, C sees self gone=$removed_c"

  prompt_human "In Amethyst, open 'Interop-05' -> Group Info and confirm C is no longer listed."
  if ! confirm "Does Amethyst's member list now show only you and B (not C)?"; then
    record_result "06 member removal" fail "Amethyst still shows C as a member"
    return
  fi

  prompt_human "In Amethyst, send: 'after removing C'"
  if wait_for_message B "$gid" "after removing C" 30; then
    info "B still receives messages (expected — B + A are the remaining members)"
  else
    fail_msg "B stopped receiving messages (unexpected)"
    record_result "06 member removal" fail "B lost access"; return
  fi

  # C should NOT be able to decrypt the new message — forward secrecy
  # at the post-remove epoch means C's retained keys cannot open any
  # kind:445 sealed under the new epoch's exporter.
  sleep 5
  if wait_for_message C "$gid" "after removing C" 10; then
    fail_msg "C still decrypted a post-removal message — forward secrecy broken"
    record_result "06 member removal" fail "forward secrecy broken"
  else
    pass_msg "C correctly cannot read post-removal messages"
    record_result "06 member removal" pass
  fi
}

test_07_metadata_rename() {
  banner "Test 07 — Group metadata rename round-trip (MIP-01)"

  # Forward direction: A (admin of Interop-02) renames → B observes.
  local gid_a
  gid_a=$(load_state GROUP_02 || true)
  if [[ -z "${gid_a:-}" ]]; then
    skip_msg "no GROUP_02"; record_result "07a metadata rename A->B" skip "no group state"
  else
    prompt_human "In Amethyst, open group 'Interop-02' -> Group Info -> Edit.
  Rename to: Interop-02-renamed
  Save."

    step "polling B for name change (60s)"
    local deadline_a=$(( $(date +%s) + 60 )) name_a=""
    while [[ $(date +%s) -lt $deadline_a ]]; do
      name_a=$(wn_b --json groups show "$gid_a" 2>/dev/null | jq -r '(.result // .) | .name // empty')
      [[ "$name_a" == "Interop-02-renamed" ]] && break
      sleep 3
    done
    if [[ "$name_a" == "Interop-02-renamed" ]]; then
      pass_msg "B sees renamed Interop-02"
      record_result "07a metadata rename A->B" pass
    else
      fail_msg "B still sees name: $name_a"
      record_result "07a metadata rename A->B" fail "rename did not propagate to B"
    fi
  fi

  # Reverse direction: B (admin of Interop-03) renames → A observes.
  #
  # The previous revision issued the reverse rename on Interop-02 via
  # `wn_b groups rename` — but B is a plain member of Interop-02, so
  # MIP-03's `enforceAuthorizedProposalSet` rejects the GCE commit on
  # the wn side and the rename never reaches any relay. Use the group
  # B actually owns (Interop-03) for the reverse leg instead.
  local gid_b
  gid_b=$(load_state GROUP_03 || true)
  if [[ -z "${gid_b:-}" ]]; then
    skip_msg "no GROUP_03 — skipping reverse rename"
    record_result "07b metadata rename B->A" skip "no GROUP_03"
    return
  fi

  step "B (admin) renames Interop-03 -> Interop-03-renamed"
  local rename_out
  if ! rename_out=$(wn_b groups rename "$gid_b" "Interop-03-renamed" 2>&1); then
    warn "wn_b groups rename returned nonzero: $rename_out"
    printf 'wn_b groups rename: %s\n' "$rename_out" >>"$LOG_FILE"
  fi

  if confirm "Does Amethyst now show the group previously named 'Interop-03' as 'Interop-03-renamed'?"; then
    record_result "07b metadata rename B->A" pass
  else
    record_result "07b metadata rename B->A" fail "Amethyst did not pick up rename"
  fi
}

test_08_admin_promote_demote() {
  banner "Test 08 — Admin promote/demote"

  local gid
  gid=$(load_state GROUP_03 || true)
  if [[ -z "${gid:-}" ]]; then
    skip_msg "no GROUP_03"; record_result "08 admin promote/demote" skip "no group"; return
  fi

  step "B (creator+admin) adds C so we have 3 members"
  wn_c keys publish >/dev/null 2>&1 || true
  sleep 3
  local c_ignore_08 c_new_gid_08
  c_ignore_08=$(snapshot_invites C)
  wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || warn "add-members C returned nonzero"
  if c_new_gid_08=$(wait_for_invite C 30 "$c_ignore_08"); then
    wn_c groups accept "$c_new_gid_08" >/dev/null 2>&1 || true
  fi

  step "B promotes A to admin"
  if ! wn_b groups promote "$gid" "$A_NPUB" >/dev/null 2>&1; then
    fail_msg "promote failed"
    record_result "08 admin promote/demote" fail "promote returned nonzero"; return
  fi

  step "verifying admin list contains both B and A"
  local admins
  admins=$(wn_b --json groups admins "$gid" 2>/dev/null | jq -r '(.result // .) | .[].pubkey // .[].public_key // .[]' | tr '\n' ' ')
  if expect_contains "$admins" "$A_HEX" "admin list" && expect_contains "$admins" "$B_HEX" "admin list"; then
    pass_msg "admin promote worked"
  else
    record_result "08 admin promote/demote" fail "admin list missing A or B"; return
  fi

  step "B demotes A"
  wn_b groups demote "$gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero"
  sleep 5
  admins=$(wn_b --json groups admins "$gid" 2>/dev/null | jq -r '(.result // .) | .[].pubkey // .[].public_key // .[]' | tr '\n' ' ')
  if [[ "$admins" != *"$A_HEX"* ]]; then
    pass_msg "demote removed A"
    record_result "08 admin promote/demote" pass
  else
    record_result "08 admin promote/demote" fail "A still admin after demote"
  fi
}

test_09_reply_react_unreact() {
  banner "Test 09 — Reply / react / unreact (inner event kinds 9, 7)"

  local gid
  gid=$(load_state GROUP_02 || true)
  if [[ -z "${gid:-}" ]]; then
    skip_msg "no GROUP_02"; record_result "09 reply/react" skip "no group"; return
  fi

  step "B sends anchor message"
  wn_b messages send "$gid" "anchor for reactions" >/dev/null 2>&1 || true
  sleep 3
  local msg_id
  msg_id=$(wn_b --json messages list "$gid" --limit 10 2>/dev/null \
            | jq -r '(.result // .) | [.[]? | select((.content // .text // "") == "anchor for reactions")][0].id // .[0].event_id // empty' 2>/dev/null || true)
  if [[ -z "$msg_id" || "$msg_id" == "null" ]]; then
    fail_msg "could not find anchor message id"
    record_result "09 reply/react" fail "anchor id missing"; return
  fi
  info "anchor id: $msg_id"

  step "B reacts with 🌮 and then unreacts"
  wn_b messages react "$gid" "$msg_id" "🌮" >/dev/null 2>&1 || true
  sleep 3
  if ! confirm "Does Amethyst show 🌮 reaction on 'anchor for reactions'?"; then
    record_result "09 reply/react" fail "reaction not visible in Amethyst"; return
  fi
  wn_b messages unreact "$gid" "$msg_id" >/dev/null 2>&1 || true
  sleep 3
  if ! confirm "Did Amethyst remove the 🌮 reaction?"; then
    record_result "09 reply/react" fail "unreact not visible"; return
  fi

  step "B replies to the anchor"
  # clap v4 converts snake_case fields to kebab-case flags by default, so the
  # `reply_to: Option<String>` field on `wn messages send` exposes as
  # `--reply-to`. Passing `--reply_to` is silently rejected (the script's
  # `|| true` hides the error) and the reply is never actually published.
  wn_b messages send "$gid" "replying via wn" --reply-to "$msg_id" >/dev/null 2>&1 || true
  sleep 3
  if confirm "Does Amethyst show 'replying via wn' as a threaded reply to the anchor?"; then
    record_result "09 reply/react" pass
  else
    record_result "09 reply/react" fail "reply not threaded"
  fi
}

test_10_concurrent_commits() {
  banner "Test 10 — Concurrent commits (race)"

  local gid
  gid=$(load_state GROUP_02 || true)
  if [[ -z "${gid:-}" ]]; then
    skip_msg "no GROUP_02"; record_result "10 concurrent commits" skip "no group"; return
  fi

  prompt_human "Get ready to trigger two conflicting commits SIMULTANEOUSLY.
  In Amethyst: open group 'Interop-02' -> Group Info -> Edit name.
  Type a new name (e.g. 'race-from-amethyst') but DO NOT SAVE YET.
  Press <Enter> here; the script will immediately issue a conflicting rename from B.
  Then within 1 second tap SAVE in Amethyst."

  ( sleep 0; wn_b groups rename "$gid" "race-from-wn" >/dev/null 2>&1 ) &
  local pid=$!
  info "issued wn rename (pid $pid). Tap SAVE in Amethyst NOW."
  wait "$pid" 2>/dev/null || true

  step "sleeping 10s for both sides to converge"
  sleep 10

  step "verifying B's view is consistent (name + epoch)"
  local b_view
  # Post-v0.2 wn wraps `groups show` output in {"result": {...}}; peel
  # that wrapper before extracting the fields so the info log isn't
  # "name: null, epoch: null" on every run.
  b_view=$(wn_b --json groups show "$gid" 2>/dev/null | jq '(.result // .) | {name, epoch}' 2>/dev/null || echo "{}")
  info "B state: $b_view"

  prompt_human "Read the group name now shown in Amethyst."
  if confirm "Do Amethyst and wn (B view above) agree on the same group name?"; then
    step "post-race message exchange"
    wn_b messages send "$gid" "post-race ping" >/dev/null 2>&1 || true
    if confirm "Does Amethyst show 'post-race ping'? (proves encryption still works)"; then
      record_result "10 concurrent commits" pass
    else
      record_result "10 concurrent commits" fail "encryption broken after race"
    fi
  else
    record_result "10 concurrent commits" fail "diverged state after race"
  fi
}

test_11_leave_group() {
  banner "Test 11 — Leave group"

  local gid
  gid=$(load_state GROUP_02 || true)
  if [[ -z "${gid:-}" ]]; then
    skip_msg "no GROUP_02"; record_result "11 leave group" skip "no group"; return
  fi

  prompt_human "In Amethyst, open group 'Interop-02' -> Group Info -> Leave Group.
  Confirm the action."

  step "verifying B's member list no longer contains A (60s poll)"
  local deadline=$(( $(date +%s) + 60 )) gone=0
  while [[ $(date +%s) -lt $deadline ]]; do
    if ! wn_b --json groups members "$gid" 2>/dev/null \
         | jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' >/dev/null 2>&1; then
      gone=1; break
    fi
    sleep 3
  done
  if [[ "$gone" -eq 1 ]]; then
    pass_msg "A removed from B's view of the group"
    record_result "11 leave group" pass
  else
    record_result "11 leave group" fail "A still in member list after leave"
  fi
}

test_12_offline_catchup() {
  banner "Test 12 — Offline catch-up (replay multiple commits)"

  # Use a fresh group for cleanliness
  wn_c keys publish >/dev/null 2>&1 || true
  sleep 3
  local out gid
  out=$(wn_b --json groups create "Interop-12" "$A_NPUB" 2>>"$LOG_FILE")
  printf 'wn groups create raw output: %s\n' "$out" >>"$LOG_FILE"
  gid=$(printf '%s' "$out" | jq_group_id)
  if [[ -z "$gid" ]]; then
    fail_msg "could not create Interop-12"; record_result "12 offline catchup" fail "create failed"; return
  fi
  save_state GROUP_12 "$gid"

  prompt_human "In Amethyst, accept the new invite 'Interop-12' and confirm you see the group."

  prompt_human "Now force-Amethyst offline:
    - Turn on Airplane Mode, OR
    - Force-stop the app from Android settings, OR
    - On emulator: adb shell svc wifi disable
  Press <Enter> when Amethyst is offline."

  step "B sends 5 messages"
  for i in 1 2 3 4 5; do
    wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true
    sleep 1
  done

  step "B adds C, then sends 3 more messages"
  local c_ignore_12 c_new_gid_12
  c_ignore_12=$(snapshot_invites C)
  wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || true
  if c_new_gid_12=$(wait_for_invite C 30 "$c_ignore_12"); then
    wn_c groups accept "$c_new_gid_12" >/dev/null 2>&1 || true
  fi
  for i in 6 7 8; do
    wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true
    sleep 1
  done

  step "B renames the group"
  wn_b groups rename "$gid" "Interop-12-renamed" >/dev/null 2>&1 || true

  prompt_human "Now bring Amethyst back online and open group 'Interop-12-renamed'.
  Wait ~10 seconds for replay."

  if confirm "Does Amethyst show all 8 messages (offline-msg-1..8) AND the new name 'Interop-12-renamed' AND C as a member?"; then
    record_result "12 offline catchup" pass
  else
    record_result "12 offline catchup" fail "replay incomplete"
  fi
}

test_13_keypackage_rotation() {
  banner "Test 13 — KeyPackage rotation (MIP-00 consumption-driven)"

  # Per MIP-00, a KeyPackage is rotated when it's *consumed* by an incoming
  # Welcome — the init_key is effectively spent and the client must
  # publish a fresh KeyPackage to the same d-tag slot.
  #
  # Earlier revisions of this test asked the human to restart Amethyst or
  # toggle KP relays, on the assumption that either would republish a
  # fresh KP. Neither actually does in current Amethyst:
  #   - Restart: `Account.ensureMarmotKeyPackagePublished` early-returns
  #     whenever a bundle already exists on disk — no rotation.
  #   - Toggle KP relays: `Account.saveKeyPackageRelayList` re-publishes
  #     the existing kind:30443 to the new relay set, same event_id.
  #
  # So drive the real MIP-00 flow instead: have B create a one-off group
  # with A as invitee. The Welcome gift-wrap delivered to A consumes A's
  # KeyPackage, `DecryptAndIndexProcessor` sees `needsKeyPackageRotation`
  # on the ingest result, and calls `publishMarmotKeyPackages` which
  # rotates the consumed slot and publishes a new kind:30443 to the same
  # d-tag. B's `keys check` then returns a different event_id.
  #
  # Rotation fires on Welcome *receive* (decrypt), not on human accept, so
  # the operator's accept is nice-to-have for group hygiene but not on the
  # critical path for this test.

  step "B records A's current KP event id"
  local before
  before=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.result.event_id // .event_id // empty')
  info "A KP before rotation: $before"
  if [[ -z "$before" ]]; then
    fail_msg "no existing KP for A"; record_result "13 keypackage rotation" fail "no KP found"; return
  fi

  step "B creates group 'Interop-13-rotation' with A as sole invitee (consumes A's KP)"
  local out gid
  out=$(wn_b --json groups create "Interop-13-rotation" "$A_NPUB" 2>>"$LOG_FILE")
  printf 'wn groups create (test_13) raw output: %s\n' "$out" >>"$LOG_FILE"
  gid=$(printf '%s' "$out" | jq_group_id)
  if [[ -z "$gid" ]]; then
    fail_msg "could not create Interop-13-rotation"
    record_result "13 keypackage rotation" fail "create failed"
    return
  fi
  save_state GROUP_13 "$gid"
  info "group_id: $gid"

  prompt_human "In Amethyst, you should see a new invite 'Interop-13-rotation' from $B_NPUB.
  Accepting is NOT required for rotation (processing the Welcome is enough),
  but please accept it to keep group state tidy. Press <Enter> when done."

  step "waiting for A's rotated KP event_id at B (60s)"
  local deadline=$(( $(date +%s) + 60 )) after=""
  while [[ $(date +%s) -lt $deadline ]]; do
    after=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.result.event_id // .event_id // empty')
    [[ -n "$after" && "$after" != "$before" ]] && break
    sleep 3
  done
  if [[ -n "$after" && "$after" != "$before" ]]; then
    pass_msg "A's KP rotated after Welcome consumption: $before -> $after"
    record_result "13 keypackage rotation" pass
  else
    fail_msg "A's KP event_id unchanged ($before) 60s after Welcome — MIP-00 rotation did not fire"
    record_result "13 keypackage rotation" fail "no new KP event_id after invite"
  fi
}

test_14_push_notifications() {
  banner "Test 14 — Push notifications (MIP-05)"

  if [[ "$ENABLE_TRANSPONDER" -ne 1 ]]; then
    skip_msg "use --transponder to enable"
    record_result "14 push notifications" skip "not requested"
    return
  fi

  local gid
  gid=$(load_state GROUP_12 || load_state GROUP_02 || true)
  if [[ -z "${gid:-}" ]]; then
    skip_msg "no group state available"; record_result "14 push notifications" skip "no group"; return
  fi

  step "B subscribes to notifications"
  wn_b notifications subscribe "$gid" >/dev/null 2>&1 || warn "notifications subscribe returned nonzero"

  prompt_human "In Amethyst, send: 'ping for push'"
  sleep 5
  if confirm "Did B's transponder instance receive a push notification log entry (check $B_DIR/logs/stderr.log)?"; then
    record_result "14 push notifications" pass
  else
    record_result "14 push notifications" fail "no push observed"
  fi
}

# ==== main ===================================================================

main() {
  # Make sure Ctrl+C / SIGTERM / SIGHUP all run the full cleanup path —
  # otherwise wnd is nohup'd and keeps running after the script dies.
  # Trap INT/TERM/HUP forces `exit`, which triggers the EXIT handler once.
  cleanup() {
    local rc=$?
    trap - EXIT INT TERM HUP
    stop_daemons
    stop_local_relay
    print_summary
    exit "$rc"
  }
  trap cleanup EXIT
  trap 'exit 130' INT
  trap 'exit 143' TERM
  trap 'exit 129' HUP
  banner "Amethyst <-> MDK interop harness ($RUN_TS)"

  preflight
  if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then
    RELAY_LIST=( "${DEFAULT_RELAYS[@]}" )
  else
    RELAY_LIST=( "$RELAY_URL" )
    start_local_relay
  fi
  start_daemon B "$B_DIR" "$B_SOCKET"
  start_daemon C "$C_DIR" "$C_SOCKET"
  ensure_identity B
  ensure_identity C
  prompt_for_a_npub
  configure_relays

  # Baseline daemon dumps are no longer run unconditionally — they produce
  # 100+ lines per daemon and the happy path doesn't need them. Each test
  # that polls for an invite still calls `dump_daemon_diagnostics` on
  # timeout, which is the failure mode those dumps are meant to diagnose.

  # Let wn discover A's advertised relays via its normal subscription
  # plane, then summarise what wn sees. Surfaces up front the kind of
  # failure (A's 10050 unreachable from wn, missing KP list, etc.) that
  # would otherwise bite as a silent Test 03 timeout.
  if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then
    discover_a_relays
  fi

  instruct_amethyst_setup

  test_01_keypackage_discovery
  test_02_amethyst_creates_group
  test_03_wn_creates_group
  test_04_three_member_group
  test_05_wn_adds_amethyst_existing
  test_06_member_removal
  test_07_metadata_rename
  test_08_admin_promote_demote
  test_09_reply_react_unreact
  test_10_concurrent_commits
  test_11_leave_group
  test_12_offline_catchup
  test_13_keypackage_rotation
  test_14_push_notifications
}

main "$@"
