#!/usr/bin/env bash
# apply-radio.sh — compute (and, when enabled, apply) a radio_table config change across a set of APs.
# DRY-RUN BY DEFAULT: prints the exact per-AP before->after, the REST payload, and captured rollback
# values. Writes are GATED off until a read-WRITE controller admin is vaulted AND --apply is passed
# (production safety; live facility). Roll out per --zone, validate with watch-ap.sh before+after.
#
# WHY API, not SSH/Mongo: UniFi config is controller-authoritative. The supported write path is the
# controller REST API (PUT /proxy/network/api/s/<site>/rest/device/<id>) with an ADMIN SESSION. The
# root SSH key is the data plane (reads/AP-watch), NOT an API write credential.
#
# Usage:
#   bash .../apply-radio.sh <site> <band ng|na|6e> <action> <value> [--zone "Floor 3"] [--apply]
# Actions / values (all are radio_table fields):
#   power    low|medium|high|auto|<dBm>     -> tx_power_mode (+ tx_power if a dBm number)
#   width    20|40|80|160                   -> ht  (channel width)
#   channel  <number>|auto                  -> channel
#   minrssi  off|on|-<NN>                   -> min_rssi_enabled (+ min_rssi if a dBm number)
#   disable                                -> tx_power_mode=disabled  (turn the radio OFF)
#   enable                                 -> tx_power_mode=auto       (turn it back ON)
# (radio disable == tx_power_mode "disabled"; confirmed 2026-06-16 by UI-toggle + device-JSON diff)
# Examples:
#   apply-radio.sh cascades na width 40                 # preview: 5GHz -> 40MHz everywhere
#   apply-radio.sh cascades na width 40 --zone "Floor 4" --apply
#   apply-radio.sh cascades ng minrssi -76 --apply      # enable 2.4 min-RSSI -76
#   apply-radio.sh cascades 6e power low --zone "Floor 6"
set -euo 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-radio" "$1" --context "${2:-}" >/dev/null 2>&1 || true; }
SITEARG="${1:?usage: apply-radio.sh <site> <band> <action> [value] [--zone Z] [--ap NAME] [--apply]}"
BAND="${2:?band ng|na|6e}"; ACT="${3:?action: power|width|channel|minrssi|disable|enable}"
shift 3
VAL=""   # disable/enable take no value; the others consume the next positional as the value
case "$ACT" in power|width|channel|minrssi) VAL="${1:-}"; shift || true;; esac
ZONE=""; APN=""; APPLY=0
while [ $# -gt 0 ]; do case "$1" in --zone) ZONE="$2"; shift 2;; --ap) APN="$2"; shift 2;; --apply) APPLY=1; shift;; *) shift;; esac; done
case "$BAND" in ng|na|6e) ;; *) echo "band must be ng|na|6e"; exit 1;; esac

# action+value -> the radio_table fields to set (compact JSON, used by both the preview JS and apply python)
case "$ACT" in
  power)
    if [[ "$VAL" =~ ^-?[0-9]+$ ]]; then FIELDS="{\"tx_power_mode\":\"custom\",\"tx_power\":$VAL}";
    else case "$VAL" in low|medium|high|auto) FIELDS="{\"tx_power_mode\":\"$VAL\"}";; *) echo "power: low|medium|high|auto|<dBm>"; exit 1;; esac; fi ;;
  width)   case "$VAL" in 20|40|80|160) FIELDS="{\"ht\":$VAL}";; *) echo "width: 20|40|80|160"; exit 1;; esac ;;
  channel) if [[ "$VAL" =~ ^[0-9]+$ ]]; then FIELDS="{\"channel\":$VAL}"; elif [ "$VAL" = auto ]; then FIELDS="{\"channel\":\"auto\"}"; else echo "channel: <number>|auto"; exit 1; fi ;;
  minrssi) case "$VAL" in
             off) FIELDS="{\"min_rssi_enabled\":false}";;
             on)  FIELDS="{\"min_rssi_enabled\":true}";;
             -[0-9]*) FIELDS="{\"min_rssi_enabled\":true,\"min_rssi\":$VAL}";;
             *) echo "minrssi: off|on|-<NN>"; exit 1;; esac ;;
  disable) FIELDS="{\"tx_power_mode\":\"disabled\"}";;   # turn the radio OFF (confirmed via UI diff 2026-06-16)
  enable)  FIELDS="{\"tx_power_mode\":\"auto\"}";;        # turn it back ON (auto power)
  *) echo "action must be power|width|channel|minrssi|disable|enable"; exit 1;;
esac

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; }
echo "[INFO] site=$SITE band=$BAND  set $FIELDS${ZONE:+  zone='$ZONE'}${APN:+  ap='$APN'}  mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)"

# ---- DRY-RUN preview: compare target fields vs current radio_table (Mongo) ----
cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need'
var SITE='$SITE',BAND='$BAND',FIELDS=$FIELDS;
function zoneOf(n){var fz=String(n||'').match(/(\d)(?:st|nd|rd|th)\s*floor/i),rm=String(n||'').match(/\b(\d)\d{2}\b/);return fz?('Floor '+fz[1]):(rm?('Floor '+rm[1]):'misc');}
var n=0,skip=0;
db.device.find({site_id:SITE,type:'uap'},{name:1,radio_table:1}).forEach(function(a){
  if('$ZONE' && zoneOf(a.name)!=='$ZONE') return;
  if('$APN' && (a.name||'')!=='$APN') return;
  (a.radio_table||[]).forEach(function(r){ if(r.radio!==BAND) return;
    var change=false,roll={};
    for(var f in FIELDS){ roll[f]=(r[f]!==undefined?r[f]:null); if(String(r[f])!==String(FIELDS[f])) change=true; }
    if(!change){ skip++; return; }
    n++; print("CHANGE  "+(a.name||a._id)+"  ["+BAND+"]  "+JSON.stringify(roll)+" -> "+JSON.stringify(FIELDS));
  });
});
print("\nSUMMARY: "+n+" radios would change, "+skip+" already at target.");
print("REST PUT /proxy/network/api/s/<site>/rest/device/<id> body: { radio_table:[ {radio:'"+BAND+"', ...current..., "+Object.keys(FIELDS).map(function(k){return k+':'+JSON.stringify(FIELDS[k]);}).join(', ')+"} ] }");
JS

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

# ---- WRITE PATH (controller REST: login -> per-AP GET/modify/PUT, with 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
  cat <<EOF

[BLOCKED] --apply needs a read-WRITE controller admin vaulted at: $RWP
Create in UniFi OS -> Settings -> Admins (Full Management), then:
  bash .claude/skills/vault/scripts/vault-helper.sh new $RWP --kind generic \\
    --name 'UOS Network API (read-write admin)' --tag unifi --set username=<u> --set password=<pw>
EOF
  exit 2
fi

export AR_SITE="$SITE" AR_BAND="$BAND" AR_FIELDS="$FIELDS" AR_ZONE="$ZONE" AR_AP="$APN" REPO
rc=0; python - <<'PY' || rc=$?
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['AR_SITE']),None)
if not short:print("[ERROR] site resolve failed");sys.exit(1)
band=os.environ['AR_BAND'];zone=os.environ['AR_ZONE'];apn=os.environ.get('AR_AP','');fields=json.loads(os.environ['AR_FIELDS'])
def zof(n):
    import re;n=n or ''
    m=re.search(r'(\d)(?:st|nd|rd|th)\s*floor',n,re.I) or re.search(r'\b(\d)\d{2}\b',n)
    return ('Floor '+m.group(1)) if m else 'misc'
devs=json.loads(call('GET',f'/proxy/network/api/s/{short}/stat/device')).get('data',[])
roll=[];done=0;fail=0
for d in devs:
    if d.get('type')!='uap':continue
    if zone and zof(d.get('name'))!=zone:continue
    if apn and d.get('name')!=apn:continue
    rt=d.get('radio_table') or [];changed=False;old=[]
    for r in rt:
        if r.get('radio')!=band:continue
        old.append({f:r.get(f) for f in fields})
        if all(str(r.get(f))==str(v) for f,v in fields.items()):continue
        for f,v in fields.items():r[f]=v
        changed=True
    if not changed:continue
    try:
        call('PUT',f"/proxy/network/api/s/{short}/rest/device/{d['_id']}",{'radio_table':rt},csrf=csrf)
        roll.append({'id':d['_id'],'name':d.get('name'),'old':old});done+=1;print(f"  [ok] {d.get('name')} -> {band} {fields}")
    except Exception as e:
        fail+=1;print(f"  [FAIL] {d.get('name')}: {e}")
rp=os.path.join(os.environ.get('REPO','.'),'.claude','tmp',f"apply-rollback-{short}-{band}-{'-'.join(fields)}.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)
print("[validate] watch the target APs live: watch-ap.sh <ap-ip>  (before/after). Roll out per --zone.")
PY
[ "$rc" -ne 0 ] && logerr "apply-radio REST write failed" "site=$SITE band=$BAND act=$ACT rc=$rc"
exit $rc
