#!/usr/bin/env bash
# dfs-check.sh — empirical DFS radar-event history across a site's APs (the real DFS reality check).
#
# UniFi does NOT surface radar/DFS events in the controller DB/API. Each AP's kernel ring buffer
# (`dmesg`) records actual radar detections + channel-availability-check (CAC) events. This sweeps
# every AP, greps `dmesg` with PRECISE patterns (the loose `cac`/`dfs` grep false-matches "cached
# ifindex" — excluded here), and reports which APs on DFS channels (52-144) have actually been hit.
# Answers "is DFS safe to use at this site?" with data (matters near military/airport radar).
# Non-disruptive read. Works for any UOS site.
#
# Needs: controller cred (infrastructure/uos-server-network-api-rw, for AP list+5GHz channels) +
# per-site AP device-auth SSH cred + site VPN reach. RUN IN FOREGROUND.
#
# Usage: bash .claude/skills/unifi-wifi/scripts/dfs-check.sh <site-name|id> [ap-ssh-vault-path]
set -uo pipefail
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
VAULT="$REPO/.claude/scripts/vault.sh"
# Mandatory skill error logging (skill-creator rule): log GENUINE functional failures only.
logerr(){ bash "$REPO/.claude/scripts/log-skill-error.sh" "unifi-wifi/dfs-check" "$1" --context "${2:-}" >/dev/null 2>&1 || true; }
HOST="${UOS_HOST:-172.16.3.29}"; PORT="${UOS_HTTPS_PORT:-11443}"
SITEARG="${1:?usage: dfs-check.sh <site-name|id> [ap-ssh-vault-path]}"
VP="${2:-clients/cascades-tucson/unifi-ap-ssh}"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT

CU="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.username 2>/dev/null)"
CP="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.password 2>/dev/null)"
[ -n "$CU" ] && [ -n "$CP" ] || { echo "[ERROR] no controller cred (infrastructure/uos-server-network-api-rw)"; exit 1; }
base="https://$HOST:$PORT"; CJ="$TMP/cj"
code=$(curl -sk -c "$CJ" -o /dev/null -w '%{http_code}' -X POST "$base/api/auth/login" -H 'Content-Type: application/json' \
  --data-binary "$(python -c 'import json,sys;print(json.dumps({"username":sys.argv[1],"password":sys.argv[2]}))' "$CU" "$CP")")
[ "$code" = "200" ] || { echo "[ERROR] controller login HTTP $code"; logerr "UOS controller login failed (HTTP $code)" "host=$HOST:$PORT site=$SITEARG"; exit 1; }
SHORT="$(curl -sk -b "$CJ" "$base/proxy/network/api/self/sites" | python -c "
import sys,json; d=json.load(sys.stdin).get('data',[]); q='''$SITEARG'''.lower()
for s in d:
  if s.get('_id')=='''$SITEARG''' or s.get('name')=='''$SITEARG''' or q in (s.get('desc','').lower()): print(s.get('name')); break
")"; [ -n "$SHORT" ] || SHORT="$SITEARG"
echo "[INFO] site=$SHORT"
# AP list with current 5GHz channel (flag DFS APs).
# NOTE: temp paths via ARGV (MSYS translates POSIX->Windows for python.exe); $TMP inside a
# `python -c` string is not translated and fails on Windows.
curl -sk -b "$CJ" "$base/proxy/network/api/s/$SHORT/stat/device" -o "$TMP/dev.json"
python - "$TMP/dev.json" "$TMP/aps.tsv" <<'PY'
import sys,json
out=[]
for a in json.load(open(sys.argv[1])).get('data',[]):
    if a.get('type')!='uap' or a.get('state')!=1 or not a.get('ip'): continue
    na=[r for r in a.get('radio_table_stats',[]) if r.get('radio')=='na']
    ch=na[0].get('channel') if na else '?'
    try: dfs='DFS' if 52<=int(ch)<=144 else 'clear'
    except: dfs='?'
    out.append(f"{a.get('name') or a.get('mac')}\t{a.get('ip')}\t{ch}\t{dfs}")
open(sys.argv[2],'w',newline='\n').write('\n'.join(out))
print(f"[INFO] {len(out)} online APs; {sum(1 for x in out if chr(9)+'DFS' in x)} currently on DFS 5GHz channels")
PY

AU="$(bash "$VAULT" get-field "$VP" credentials.username 2>/dev/null)"
AP_PW="$(bash "$VAULT" get-field "$VP" credentials.password 2>/dev/null)"; export AP_PW
[ -n "$AU" ] && [ -n "$AP_PW" ] || { echo "[BLOCKED] no AP device-auth cred at vault:$VP"; echo "  Controller-side scripts (audit-site/live-stats/model-rank/optimize-radios/apply-radio) still work for this site."; echo "  For AP-side collectors, vault this client's cred then re-run:"; echo "    bash .claude/skills/vault/scripts/vault-helper.sh new $VP --kind generic --name '<Client> UniFi AP device-auth SSH' --tag unifi --set username=<u> --set password=<pw>"; exit 2; }
SSH_OPTS=(-o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \
          -o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1)
if command -v sshpass >/dev/null 2>&1; then
  ap_ssh() { SSHPASS="$AP_PW" sshpass -e ssh "${SSH_OPTS[@]}" "$@" </dev/null; }
else
  ASKP="$TMP/askpass.sh"; printf '#!/bin/sh\nprintf "%%s\\n" "$AP_PW"\n' > "$ASKP"; chmod +x "$ASKP"
  ap_ssh() { SSH_ASKPASS="$ASKP" SSH_ASKPASS_REQUIRE=force DISPLAY="${DISPLAY:-:0}" ssh "${SSH_OPTS[@]}" "$@" </dev/null; }
fi

# PRECISE radar/DFS patterns; exclude the "cached ifindex" false positive
PAT='radar|DFS|NOL|channel availability|CAC '
echo "==== DFS RADAR EVENTS (dmesg per AP; '* on DFS now') ===="
n=0; tot=$(wc -l < "$TMP/aps.tsv"); hits=0
while IFS=$'\t' read -r name ip ch dfs; do
  ip="${ip%$'\r'}"; dfs="${dfs%$'\r'}"; [ -z "$ip" ] && continue; n=$((n+1))
  printf '\r[INFO] checking %d/%d   ' "$n" "$tot" >&2
  ev=""; t=0; reach=0   # retry per AP (transient VPN flaps); ap_ssh rc distinguishes unreachable from no-events
  while [ $t -lt 3 ]; do if ev="$(ap_ssh "$AU@$ip" "dmesg 2>/dev/null | grep -iE '$PAT' | grep -iv 'cached' | tail -4" 2>/dev/null)"; then reach=1; break; fi; t=$((t+1)); sleep 2; done
  [ "$reach" = 1 ] || { echo "[UNREACHABLE] $name ($ip) - skipped"; continue; }
  cnt=$(printf '%s' "$ev" | grep -c . )
  mark=$([ "$dfs" = "DFS" ] && echo ' *' || echo '')
  if [ "$cnt" -gt 0 ]; then
    hits=$((hits+1))
    echo "[RADAR] $name (5GHz ch$ch$mark): $cnt event(s)"
    printf '%s\n' "$ev" | sed 's/^/      /'
  fi
done < "$TMP/aps.tsv"; echo "" >&2
echo ""
if [ "$hits" -eq 0 ]; then
  echo "[OK] No real radar/DFS events found in any AP's dmesg -> DFS appears low-risk at this site."
  echo "     (dmesg is bounded; re-run periodically. Absence over long uptime = strong signal DFS is usable.)"
else
  echo "[WARNING] $hits AP(s) logged radar/DFS events -> DFS is being hit; prefer non-DFS (UNII-1 36-48 + UNII-3 149-165)."
fi
