#!/usr/bin/env bash
# neighbor-collect.sh — harvest the AP-to-AP RF-neighbor SNR matrix for a UniFi site.
#
# THE KEY DISCOVERY (2026-06-15): UniFi does NOT expose managed-AP-to-managed-AP visibility
# through any documented API, the controller `rogue`/`stat/rogueap` (both filter out our own
# APs), Mongo, 802.11k hostapd, or Channel AI's channelplan. BUT each AP keeps the data
# internally in /proc, populated NON-DISRUPTIVELY by UniFi's own background RRM scanning:
#   /proc/ui_neighbor/ess_ap_list   — full list of our APs this AP hears (serial/band/channel)
#   /proc/ui_neighbor/ssid/<n>       — same neighbors WITH SNR (per scan-vap, per band)
# This script SSHes each AP, reads those, maps neighbor BSSIDs/serials -> AP names, and emits
# the AP-to-AP SNR adjacency matrix + a redundancy summary (which APs are heard strongly by >=2
# neighbors = candidates whose radio can be powered-down/disabled without a coverage hole).
# /proc/ui_neighbor exists on every UniFi AP, so this is fleet-generic (any UOS site).
#
# Reads are non-disruptive. Needs: controller cred (infrastructure/uos-server-network-api-rw,
# for the BSSID/mac->name map) + per-site AP device-auth SSH cred + L3 reach to the AP mgmt VLAN
# (the site VPN). AP SSH uses sshpass if present, else an SSH_ASKPASS fallback (Windows has no
# sshpass; the fallback needs `ssh` from PATH = MSYS ssh on Windows).
#
# Usage: bash .claude/skills/unifi-wifi/scripts/neighbor-collect.sh <site-name|id> [ap-ssh-vault-path] [snr_min]
#   e.g. bash .../neighbor-collect.sh cascades                                  # default cred + SNR>=20
#        bash .../neighbor-collect.sh cascades clients/acme/unifi-ap-ssh 25
set -uo pipefail
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
VAULT="$REPO/.claude/scripts/vault.sh"; UOS="$REPO/.claude/scripts/uos-mongo.sh"
# Mandatory skill error logging (skill-creator rule): log GENUINE functional failures only.
logerr(){ bash "$REPO/.claude/scripts/log-skill-error.sh" "unifi-wifi/neighbor-collect" "$1" --context "${2:-}" >/dev/null 2>&1 || true; }
HOST="${UOS_HOST:-172.16.3.29}"; PORT="${UOS_HTTPS_PORT:-11443}"
# Data source for the AP name/BSSID/IP map: UOS direct-login (default) OR the cloud CONNECTOR
# (`--console <name> [--site <short>]`) for non-UOS / remote consoles. The AP SSH harvest below is
# IDENTICAL either way and still needs L3 reach to the AP mgmt VLAN (the connector proxies the
# controller, not SSH to individual APs).
CONSOLE=""; CSITE=""
if [ "${1:-}" = "--console" ]; then
  CONSOLE="${2:?--console needs a console name}"; shift 2 2>/dev/null || shift
  if [ "${1:-}" = "--site" ]; then CSITE="${2:-}"; shift 2 2>/dev/null || shift; fi
  VP="${1:-}"; SNR_MIN="${2:-20}"
  [ -n "$VP" ] || { echo "[ERROR] --console mode needs the AP-SSH vault path:"; echo "  neighbor-collect.sh --console \"<name>\" [--site <short>] clients/<slug>/unifi-ap-ssh [snr_min]"; exit 2; }
else
  SITEARG="${1:?usage: neighbor-collect.sh <site> [ap-ssh-vault-path] [snr_min]  |  --console <name> [--site <short>] <ap-ssh-vault-path> [snr_min]}"
  VP="${2:-clients/cascades-tucson/unifi-ap-ssh}"; SNR_MIN="${3:-20}"
fi
NBR_JSON="${NBR_JSON:-}"   # if set, also write a machine-readable adjacency {ap:{band:{nbr:snr}}} here
                           # (consumed by optimize-radios.sh via NEIGHBOR_JSON for data-backed disables)
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT

# --- fetch the site device list (-> $TMP/dev.json) from the chosen data source ---
if [ -n "$CONSOLE" ]; then
  # CONNECTOR source: api.ui.com (no UOS login; works for any remote console)
  KEY="$(bash "$VAULT" get-field services/unifi-site-manager credentials.api_key 2>/dev/null | tr -d '\r\n')"
  [ -n "$KEY" ] || { echo "[ERROR] no UniFi Site Manager key (vault services/unifi-site-manager)"; exit 1; }
  HID="$(curl -s -H "X-API-KEY: $KEY" "https://api.ui.com/v1/hosts" | python -c "
import json,sys
q='''$CONSOLE'''.strip().lower()
for h in json.load(sys.stdin).get('data',[]):
    nm=((h.get('reportedState') or {}).get('name')) or ''
    if h.get('id','').lower()==q or q in nm.lower(): print(h['id']); break
")"
  [ -n "$HID" ] || { echo "[ERROR] no console matching '$CONSOLE' in the Site Manager fleet"; exit 1; }
  CC="https://api.ui.com/v1/connector/consoles/$HID/proxy/network"
  if [ -n "$CSITE" ]; then SHORT="$CSITE"; else
    SHORT="$(curl -s -H "X-API-KEY: $KEY" "$CC/api/self/sites" | python -c "import json,sys; d=json.load(sys.stdin).get('data',[]); print(d[0].get('name') if d else 'default')")"
  fi
  echo "[INFO] source=CONNECTOR console='$CONSOLE' site=$SHORT  SNR_MIN=$SNR_MIN"
  curl -s -H "X-API-KEY: $KEY" "$CC/api/s/$SHORT/stat/device" -o "$TMP/dev.json"
else
  # UOS direct-login source (RW admin reads fine)
  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] source=UOS site=$SHORT  SNR_MIN=$SNR_MIN"
  curl -sk -b "$CJ" "$base/proxy/network/api/s/$SHORT/stat/device" -o "$TMP/dev.json"
fi
python - "$TMP/dev.json" "$TMP" <<'PY'
import json,sys
d=[a for a in json.load(open(sys.argv[1])).get('data',[]) if a.get('type')=='uap']
macmap={}; bssmap={}; aps=[]
for a in d:
    nm=a.get('name') or a.get('mac'); mac=(a.get('mac') or '').lower(); ip=a.get('ip')
    if mac: macmap[mac]=nm
    for v in a.get('vap_table',[]):
        b=(v.get('bssid') or '').lower()
        if b: bssmap[b]=nm
    if ip and a.get('state')==1: aps.append((nm,ip))   # online only
json.dump(macmap,open(sys.argv[2]+'/macmap.json','w'))
json.dump(bssmap,open(sys.argv[2]+'/bssmap.json','w'))
open(sys.argv[2]+'/aps.tsv','w',newline='\n').write('\n'.join(f"{n}\t{i}" for n,i in aps))  # force LF (Windows text mode would write CRLF -> \r breaks ssh target)
print(f"[INFO] {len(aps)} online APs; {len(macmap)} mac + {len(bssmap)} bssid map entries")
PY

# --- AP SSH auth: sshpass if present, else SSH_ASKPASS fallback ---
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)
# NOTE: </dev/null on each ssh is REQUIRED — in the `while read` harvest loop, ssh would otherwise
# consume the loop's stdin (the AP list) and break iteration. Run this in the FOREGROUND: a fully
# detached background process can't spawn the SSH_ASKPASS helper, so every AP fails auth.
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

# --- harvest /proc/ui_neighbor from each AP into one tagged raw file ---
RAW="$TMP/raw.txt"; n=0; ok=0
while IFS=$'\t' read -r name ip; do
  ip="${ip%$'\r'}"; name="${name%$'\r'}"   # strip any CR (Windows line endings) so ssh target is valid
  [ -z "$ip" ] && continue; n=$((n+1))
  echo "###AP	$name	$ip" >> "$RAW"
  # retry per AP (transient VPN flaps); capture to var so a failed try never appends partial data
  out=""; t=0
  while [ $t -lt 3 ]; do if out="$(ap_ssh "$AU@$ip" 'echo "@@ESS"; cat /proc/ui_neighbor/ess_ap_list 2>/dev/null; for s in /proc/ui_neighbor/ssid/*; do echo "@@SSID $s"; cat "$s" 2>/dev/null; done' 2>/dev/null)" && [ -n "$out" ]; then break; fi; t=$((t+1)); sleep 2; done
  if [ -n "$out" ]; then printf '%s\n' "$out" >> "$RAW"; ok=$((ok+1)); else echo "@@UNREACHABLE" >> "$RAW"; fi
  printf '\r[INFO] harvested %d/%d (reachable %d)   ' "$n" "$(wc -l < "$TMP/aps.tsv")" "$ok" >&2
done < "$TMP/aps.tsv"
echo "" >&2

# --- parse + map + emit adjacency matrix + redundancy summary ---
python - "$RAW" "$TMP/macmap.json" "$TMP/bssmap.json" "$SNR_MIN" "${NBR_JSON:-NONE}" <<'PY'
import json,re,sys
raw=open(sys.argv[1],encoding='utf-8',errors='replace').read().splitlines()
macmap=json.load(open(sys.argv[2])); bssmap=json.load(open(sys.argv[3])); SNR_MIN=int(sys.argv[4])
OUTJSON=sys.argv[5] if len(sys.argv)>5 else 'NONE'
BN={'0':'2.4','1':'5','2':'6'}; EB={'2.4ghz':'2.4','5ghz':'5','6ghz':'6'}
# edges[(src, band)] = {neighbor_name: best_snr}; presence[(src,band)] = set(names) from ess_ap_list
edges={}; presence={}; cur=None; mode=None; band=None
def add_edge(src,band,name,snr):
    k=(src,band); edges.setdefault(k,{});
    if name not in edges[k] or snr>edges[k][name]: edges[k][name]=snr
for ln in raw:
    if ln.startswith('###AP'):
        p=ln.split('\t'); cur=p[1] if len(p)>1 else None; mode=None; band=None; continue
    if ln.startswith('@@ESS'): mode='ess'; continue
    if ln.startswith('@@SSID'): mode='ssid'; band=None; continue
    if ln.startswith('@@UNREACHABLE') or cur is None: continue
    if mode=='ess':
        p=ln.split()
        if len(p)>=3 and re.match(r'^[0-9a-f:]{17}$',p[0]):
            b=EB.get(p[1].lower()); nm=macmap.get(p[0].lower())
            if b and nm and nm!=cur: presence.setdefault((cur,b),set()).add(nm)
    elif mode=='ssid':
        mb=re.match(r'band\((\d)\)',ln.strip())
        if mb: band=BN.get(mb.group(1)); continue
        p=ln.split()
        if band and len(p)>=2 and re.match(r'^[0-9a-f:]{17}$',p[0]) and p[1].lstrip('-').isdigit():
            nm=bssmap.get(p[0].lower())
            if nm and nm!=cur: add_edge(cur,band,nm,int(p[1]))

aps=sorted({k[0] for k in list(edges)+list(presence)})
print(f"\n==== AP-to-AP RF NEIGHBOR MATRIX ({len(aps)} APs reporting) ====")
print("(SNR from /proc/ui_neighbor/ssid; '+N more' = additional neighbors seen in ess_ap_list w/o fresh SNR)\n")
for ap in aps:
    line=[f"{ap}:"]
    for b in ('2.4','5','6'):
        snrs=edges.get((ap,b),{})
        seen=presence.get((ap,b),set())
        extra=len(seen - set(snrs))
        if snrs or seen:
            top=sorted(snrs.items(),key=lambda x:-x[1])[:5]
            s=", ".join(f"{n}({v})" for n,v in top)
            more=f" +{extra} more" if extra>0 else ""
            line.append(f"\n   {b}GHz: {s}{more}")
    print("".join(line))

# redundancy summary: APs whose radio is "safe-ish" to power-down/disable = heard by >=2 neighbors at strong SNR
print(f"\n==== REDUNDANCY (neighbors at SNR>={SNR_MIN}; >=2 strong same-band neighbors = coverage-redundant) ====")
for b in ('2.4','5','6'):
    rows=[]
    for ap in aps:
        strong=[n for n,v in edges.get((ap,b),{}).items() if v>=SNR_MIN]
        rows.append((len(strong), ap, strong))
    redund=[r for r in rows if r[0]>=2]
    print(f"\n-- {b}GHz: {len(redund)}/{len(rows)} APs have >=2 strong neighbors (disable/power-down candidates) --")
    for cnt,ap,strong in sorted(redund,reverse=True)[:12]:
        print(f"   {ap}: {cnt} strong ({', '.join(strong[:4])}{'...' if len(strong)>4 else ''})")

# machine-readable adjacency for optimize-radios.sh: {ap_name: {band: {neighbor_name: snr}}}
if OUTJSON!='NONE':
    adj={}
    for (src,b),nbrs in edges.items():
        adj.setdefault(src,{})[b]=nbrs
    json.dump(adj, open(OUTJSON,'w'))
    print(f"\n[INFO] wrote adjacency JSON -> {OUTJSON} ({len(adj)} APs) - feed to optimize-radios.sh via NEIGHBOR_JSON")
PY
echo ""
echo "[next] feed the redundancy list to optimize-radios.sh; validate per-zone with watch-ap.sh before any --apply."
