#!/usr/bin/env bash
# apply-wlan.sh — WLAN-level (wlanconf) config changes that aren't on the radio: minimum data rates
# (and, extensibly, band-steering / roaming-assistant). These live on the WLAN object, so a change
# affects EVERY AP broadcasting that WLAN — bigger blast radius than apply-radio; target with --wlan.
#
# DRY-RUN by default; --apply gated behind infrastructure/uos-server-network-api-rw. Same controller
# REST write path as apply-radio (login -> GET/modify/PUT rest/wlanconf/<id>), rollback auto-saved.
#
# Actions:
#   minrate <ng|na> auto|off|<Mbps> -> minrate_setting_preference + minrate_<band>_enabled/_data_rate_kbps
#        kill 1-11Mbps legacy basic rates: set 2.4 floor to 12 or 24.  e.g. minrate ng 12
#   bandsteer <on|off>             -> no2ghz_oui  (keep 5GHz-capable client OUIs off 2.4 = band steering)
#   bands   <both|5g|5g6g|6g|all>  -> wlan_bands  (force the SSID onto specific bands; 5g = strongest steer; 6g = this controller's 6GHz token)
#   steer   <on|off>               -> roaming_assistant_na_enabled  (5GHz sticky-client kicker)
#   bsstm   <on|off>               -> bss_transition  (802.11v; assists band/AP steering + roaming)
# (modern UniFi has no classic 'bandsteering_mode' field; the above are its replacements, confirmed 2026-06-16)
#
# Usage:
#   bash .../apply-wlan.sh <site> minrate ng 12 [--wlan "CSCNet"] [--apply]
#   bash .../apply-wlan.sh <site> steer on --wlan "CSC ENT" --apply
set -uo pipefail
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
UOS="$REPO/.claude/scripts/uos-mongo.sh"; 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/apply-wlan" "$1" --context "${2:-}" >/dev/null 2>&1 || true; }
SITEARG="${1:?usage: apply-wlan.sh <site> <minrate|steer> ... [--wlan NAME] [--apply]}"
ACT="${2:?action: minrate|bandsteer|bands|steer|bsstm|wlan|dtim|mcast|bcfilter|rrm|ftroam|isolation|hidessid|macfilter|aps}"; shift 2
WLAN=""; APPLY=0
# resolve SITE now (the 'aps' action needs it to map AP names -> MACs)
if [[ "$SITEARG" =~ ^[0-9a-f]{24}$ ]]; then SITE="$SITEARG"; else
  SITE="$(bash "$UOS" --sites 2>/dev/null | grep -vi 'pq.html' | grep -i "$SITEARG" | awk '{print $1}' | head -1)"; fi
[ -n "$SITE" ] || { echo "[ERROR] site not found"; exit 1; }
# action-specific positional args -> FIELDS (the wlanconf fields to set)
case "$ACT" in
  minrate) RBAND="${1:?minrate <ng|na> <auto|off|Mbps>}"; RVAL="${2:?minrate <ng|na> <auto|off|Mbps>}"; shift 2
           case "$RBAND" in ng|na) ;; *) echo "minrate band must be ng|na"; exit 1;; esac
           # NOTE: a manual min rate is ONLY honored when minrate_setting_preference=manual (else the
           # controller auto-manages and ignores the kbps). 'auto' hands it back to the controller.
           if [ "$RVAL" = auto ]; then FIELDS="{\"minrate_setting_preference\":\"auto\"}";
           elif [ "$RVAL" = off ]; then FIELDS="{\"minrate_setting_preference\":\"manual\",\"minrate_${RBAND}_enabled\":false}";
           elif [[ "$RVAL" =~ ^[0-9]+$ ]]; then FIELDS="{\"minrate_setting_preference\":\"manual\",\"minrate_${RBAND}_enabled\":true,\"minrate_${RBAND}_data_rate_kbps\":$((RVAL*1000))}";
           else echo "minrate value: auto|off|<Mbps>"; exit 1; fi ;;
  steer)   SVAL="${1:?steer <on|off>}"; shift   # 5GHz sticky-client kicker (roaming assistant)
           case "$SVAL" in on) FIELDS="{\"roaming_assistant_na_enabled\":true}";; off) FIELDS="{\"roaming_assistant_na_enabled\":false}";; *) echo "steer: on|off"; exit 1;; esac ;;
  bandsteer) SVAL="${1:?bandsteer <on|off>}"; shift   # keep 5GHz-capable client OUIs OFF 2.4 (the band-steering toggle)
           case "$SVAL" in on) FIELDS="{\"no2ghz_oui\":true}";; off) FIELDS="{\"no2ghz_oui\":false}";; *) echo "bandsteer: on|off"; exit 1;; esac ;;
  bands)   SVAL="${1:?bands <both|5g|5g6g|all|6g>}"; shift   # per-WLAN band membership (force SSID onto bands)
           # NOTE: this controller stores 6 GHz as "6g" (verified live on Cascades Guest SSID), NOT "6e".
           case "$SVAL" in
             both) FIELDS="{\"wlan_bands\":[\"2g\",\"5g\"]}";;
             5g)   FIELDS="{\"wlan_bands\":[\"5g\"]}";;
             5g6g) FIELDS="{\"wlan_bands\":[\"5g\",\"6g\"]}";;
             6g)   FIELDS="{\"wlan_bands\":[\"6g\"]}";;
             all)  FIELDS="{\"wlan_bands\":[\"2g\",\"5g\",\"6g\"]}";;
             *) echo "bands: both|5g|5g6g|6g|all"; exit 1;; esac ;;
  bsstm)   SVAL="${1:?bsstm <on|off>}"; shift   # 802.11v BSS Transition Management (assists steering/roaming)
           case "$SVAL" in on) FIELDS="{\"bss_transition\":true}";; off) FIELDS="{\"bss_transition\":false}";; *) echo "bsstm: on|off"; exit 1;; esac ;;
  wlan)    SVAL="${1:?wlan <on|off>}"; shift   # enable/disable the whole SSID
           case "$SVAL" in on) FIELDS="{\"enabled\":true}";; off) FIELDS="{\"enabled\":false}";; *) echo "wlan: on|off"; exit 1;; esac ;;
  dtim)    DBAND="${1:?dtim <ng|na|6e> <1-255>}"; DV="${2:?dtim <ng|na|6e> <1-255>}"; shift 2
           case "$DBAND" in ng|na|6e) ;; *) echo "dtim band: ng|na|6e"; exit 1;; esac
           [[ "$DV" =~ ^[0-9]+$ ]] || { echo "dtim value: 1-255"; exit 1; }
           FIELDS="{\"dtim_mode\":\"custom\",\"dtim_${DBAND}\":$DV}" ;;   # per-band DTIM needs dtim_mode=custom
  mcast)   SVAL="${1:?mcast <on|off>}"; shift   # multicast enhancement (convert mcast->unicast)
           case "$SVAL" in on) FIELDS="{\"mcastenhance_enabled\":true}";; off) FIELDS="{\"mcastenhance_enabled\":false}";; *) echo "mcast: on|off"; exit 1;; esac ;;
  bcfilter)SVAL="${1:?bcfilter <on|off>}"; shift   # block broadcast (ARP/DHCP storms) on the WLAN
           case "$SVAL" in on) FIELDS="{\"bc_filter_enabled\":true}";; off) FIELDS="{\"bc_filter_enabled\":false}";; *) echo "bcfilter: on|off"; exit 1;; esac ;;
  rrm)     SVAL="${1:?rrm <on|off>}"; shift   # 802.11k neighbor reports (roaming assist)
           case "$SVAL" in on) FIELDS="{\"rrm_enabled\":true}";; off) FIELDS="{\"rrm_enabled\":false}";; *) echo "rrm: on|off"; exit 1;; esac ;;
  ftroam)  SVAL="${1:?ftroam <on|off>}"; shift   # 802.11r fast roaming
           case "$SVAL" in on) FIELDS="{\"fast_roaming_enabled\":true}"; echo "[WARNING] 802.11r can break legacy/medical/IoT clients - test on a scoped SSID first.";; off) FIELDS="{\"fast_roaming_enabled\":false}";; *) echo "ftroam: on|off"; exit 1;; esac ;;
  isolation) SVAL="${1:?isolation <on|off>}"; shift   # L2 client isolation
           case "$SVAL" in on) FIELDS="{\"l2_isolation\":true}";; off) FIELDS="{\"l2_isolation\":false}";; *) echo "isolation: on|off"; exit 1;; esac ;;
  hidessid) SVAL="${1:?hidessid <on|off>}"; shift
           case "$SVAL" in on) FIELDS="{\"hide_ssid\":true}";; off) FIELDS="{\"hide_ssid\":false}";; *) echo "hidessid: on|off"; exit 1;; esac ;;
  macfilter) MV="${1:?macfilter <off|allow|deny> [mac,mac,...]}"; shift   # per-WLAN MAC allow/deny list
           case "$MV" in
             off) FIELDS="{\"mac_filter_enabled\":false}";;
             allow|deny) ML="${1:?macfilter $MV <mac,mac,...>}"; shift
               macs=$(echo "$ML" | tr 'A-Z,' 'a-z\n' | sed '/^$/d; s/^/"/; s/$/"/' | paste -sd, -)
               FIELDS="{\"mac_filter_enabled\":true,\"mac_filter_policy\":\"$MV\",\"mac_filter_list\":[$macs]}" ;;
             *) echo "macfilter: off | allow <macs> | deny <macs>"; exit 1;; esac ;;
  aps)     AV="${1:?aps <ap1,ap2,...|all>}"; shift   # restrict the WLAN to specific APs (broadcasting_aps) = closest thing to 'lock to AP'
           if [ "$AV" = all ]; then FIELDS="{\"broadcasting_aps\":[]}";
           else
             macs=$(echo "$AV" | tr ',' '\n' | while IFS= read -r nm; do [ -z "$nm" ] && continue; printf 'db.device.find({site_id:"%s",type:"uap",name:"%s"},{mac:1}).forEach(function(d){print(d.mac);});\n' "$SITE" "$nm"; done | bash "$UOS" 2>/dev/null | grep -iE '^[0-9a-f:]{17}$' | sed 's/^/"/; s/$/"/' | paste -sd, -)
             [ -n "$macs" ] || { echo "[ERROR] could not resolve any AP name in: $AV"; exit 1; }
             FIELDS="{\"broadcasting_aps\":[$macs]}"
           fi ;;
  *) echo "action must be minrate|bandsteer|bands|steer|bsstm|wlan|dtim|mcast|bcfilter|rrm|ftroam|isolation|hidessid|macfilter|aps"; exit 1;;
esac
while [ $# -gt 0 ]; do case "$1" in --wlan) WLAN="$2"; shift 2;; --apply) APPLY=1; shift;; *) shift;; esac; done

echo "[INFO] site=$SITE  set $FIELDS${WLAN:+  wlan='$WLAN'}  mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)"
[ -z "$WLAN" ] && echo "[WARNING] no --wlan filter: this targets ALL WLANs at the site (incl. Guest). Consider --wlan."

# ---- DRY-RUN preview against current wlanconf (Mongo) ----
cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need'
var SITE='$SITE',FIELDS=$FIELDS,WLAN='$WLAN';
var n=0,skip=0;
db.wlanconf.find({site_id:SITE}).forEach(function(w){
  if(WLAN && w.name!==WLAN) return;
  var change=false,roll={};
  for(var f in FIELDS){ roll[f]=(w[f]!==undefined?w[f]:null); if(String(w[f])!==String(FIELDS[f])) change=true; }
  if(!change){ skip++; return; }
  n++; print("CHANGE  wlan='"+w.name+"'  "+JSON.stringify(roll)+" -> "+JSON.stringify(FIELDS));
});
print("\nSUMMARY: "+n+" WLAN(s) would change, "+skip+" already at target.");
JS

if [ "$APPLY" != "1" ]; then
  echo; echo "[dry-run] no changes made. Add --apply to write (needs the RW admin vaulted)."
  exit 0
fi

# ---- WRITE PATH (controller REST: login -> GET/modify/PUT rest/wlanconf/<id>, rollback) ----
RWP="infrastructure/uos-server-network-api-rw"
export RW_U="$(bash "$VAULT" get-field "$RWP" credentials.username 2>/dev/null || true)"
export RW_P="$(bash "$VAULT" get-field "$RWP" credentials.password 2>/dev/null || true)"
if [ -z "$RW_U" ] || [ -z "$RW_P" ]; then
  echo "[BLOCKED] --apply needs the RW controller admin vaulted at: $RWP"; exit 2; fi
export AW_SITE="$SITE" AW_FIELDS="$FIELDS" AW_WLAN="$WLAN" REPO
python - <<'PY'
import os,sys,json,ssl,urllib.request,http.cookiejar
H="172.16.3.29";PORT=11443;base=f"https://{H}:{PORT}"
ctx=ssl.create_default_context();ctx.check_hostname=False;ctx.verify_mode=ssl.CERT_NONE
cj=http.cookiejar.CookieJar();op=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj),urllib.request.HTTPSHandler(context=ctx))
def call(method,path,body=None,csrf=None,want_headers=False):
    data=json.dumps(body).encode() if body is not None else None
    r=urllib.request.Request(base+path,data=data,method=method); r.add_header('Content-Type','application/json')
    if csrf:r.add_header('X-CSRF-Token',csrf)
    resp=op.open(r,timeout=20);hdr=resp.headers;txt=resp.read().decode('utf-8','replace')
    return (txt,hdr) if want_headers else txt
try:_,hd=call('POST','/api/auth/login',{'username':os.environ['RW_U'],'password':os.environ['RW_P']},want_headers=True)
except Exception as e:print("[ERROR] login failed:",e);sys.exit(1)
csrf=hd.get('X-CSRF-Token') or hd.get('X-Updated-Csrf-Token')
sites=json.loads(call('GET','/proxy/network/api/self/sites')).get('data',[])
short=next((s['name'] for s in sites if s.get('_id')==os.environ['AW_SITE']),None)
if not short:print("[ERROR] site resolve failed");sys.exit(1)
fields=json.loads(os.environ['AW_FIELDS']);wlan=os.environ['AW_WLAN']
wl=json.loads(call('GET',f'/proxy/network/api/s/{short}/rest/wlanconf')).get('data',[])
roll=[];done=0;fail=0
for w in wl:
    if wlan and w.get('name')!=wlan:continue
    if all(str(w.get(f))==str(v) for f,v in fields.items()):continue
    old={f:w.get(f) for f in fields}
    for f,v in fields.items():w[f]=v
    try:
        call('PUT',f"/proxy/network/api/s/{short}/rest/wlanconf/{w['_id']}",w,csrf=csrf)
        roll.append({'id':w['_id'],'name':w.get('name'),'old':old});done+=1;print(f"  [ok] wlan '{w.get('name')}' -> {fields}")
    except Exception as e:
        fail+=1;print(f"  [FAIL] wlan '{w.get('name')}': {e}")
rp=os.path.join(os.environ.get('REPO','.'),'.claude','tmp',f"apply-wlan-rollback-{short}.json")
try:
    os.makedirs(os.path.dirname(rp),exist_ok=True);open(rp,'w').write(json.dumps(roll,indent=1))
    print(f"\n[APPLY] {done} changed, {fail} failed. Rollback saved: {rp}")
except Exception as e:print("[APPLY] done; rollback save failed:",e)
PY
rc=$?
[ "$rc" -ne 0 ] && logerr "apply-wlan REST write failed" "site=$SITE act=$ACT rc=$rc"
exit $rc
