#!/usr/bin/env bash
# optimize-radios.sh — capacity-aware, coverage-safe radio-pruning plan from accumulated history.
# Recommends, per band, which AP radios to POWER-DOWN (do first) and which to DISABLE (after
# re-measure), to cut airtime contention WITHOUT opening coverage holes or causing capacity
# cascades. Recommendations only; a human applies per zone with before/after validation.
#
# Design hardened via a multi-model critique (Grok + Gemini). Key principles:
#  - Coverage proxy = the ROAM GRAPH, but a neighbor only counts if roaming is BIDIRECTIONAL
#    (min(A->B,B->A) >= ROAM_MIN) and the p25 handoff RSSI clears a BAND-SPECIFIC bar
#    (2.4=-68, 5=-72, 6=-75) — proves two-way overlap, not a one-way escape from a failing AP.
#  - LOAD-SHIFT SIMULATION: disabling A pushes A's own-traffic (cu_self) onto its neighbors; only
#    disable if a strong neighbor stays under CAP (85%) after absorbing it — else POWER-DOWN.
#  - Benefit = cu_interf (the airtime you actually REMOVE) + a tx_retries (thrashing) weight;
#    cu_self is transfer cost, not removal. An AP with no strong neighbor AND high peak load is
#    ISOLATED-ESSENTIAL (keep). Zone disable cap = 40%. Stepwise: power-down -> observe -> disable.
#
# Data: ace_stat.stat_hourly (per-AP/band cu_total,cu_interf,cu_self_*,num_sta,tx_retries,satisfaction)
#       + ace_stat.wifi_connectivity_event (directional roam edges + handoff RSSI).
# Usage: bash .../optimize-radios.sh <site> [days=14] [band=ng|na|6e]
#   env: ROAM_MIN(4) CAP(85) ZONE_DISABLE_PCT(40) REDUN_NG(2) REDUN_OTHER(1)
set -euo pipefail
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
# Mandatory skill error logging (skill-creator rule): log GENUINE functional failures only.
# (Mongo read + local-JSON analysis; --console path execs rf-analyze.py -> helper for consistency, no live failure branch.)
logerr(){ bash "$REPO/.claude/scripts/log-skill-error.sh" "unifi-wifi/optimize-radios" "$1" --context "${2:-}" >/dev/null 2>&1 || true; }
# Cloud connector path (non-UOS console): `optimize-radios.sh --console "<name>" [days] [band]` routes to
# rf-analyze.py (Site Manager API; see references/site-manager-api.md). UOS Mongo path below is unchanged.
# NEIGHBOR_JSON / ROAM_MIN / CAP / ZONE_DISABLE_PCT / REDUN_* env vars pass through to the analyzer.
if [ "${1:-}" = "--console" ]; then
  shift; CONSOLE=""; CSITE="default"; CPOS=()
  while [ $# -gt 0 ]; do case "$1" in
    --site) CSITE="${2:-default}"; shift 2 2>/dev/null || shift;;
    *) if [ -z "$CONSOLE" ]; then CONSOLE="$1"; else CPOS+=("$1"); fi; shift;;
  esac; done
  [ -n "$CONSOLE" ] || { echo "usage: optimize-radios.sh --console <name> [--site <s>] [days] [band]"; exit 2; }
  CDAYS="${CPOS[0]:-14}"; CBAND="${CPOS[1]:-ng}"
  CKEY="$(bash "$REPO/.claude/scripts/vault.sh" get-field services/unifi-site-manager credentials.api_key 2>/dev/null | tr -d '\r\n')"
  [ -n "$CKEY" ] || { echo "[ERROR] no UniFi Site Manager key (vault services/unifi-site-manager)"; exit 1; }
  CPY="$(command -v py 2>/dev/null || command -v python 2>/dev/null || command -v python3)"
  exec env UNIFI_SM_KEY="$CKEY" "$CPY" "$REPO/.claude/skills/unifi-wifi/scripts/rf-analyze.py" optimize --console "$CONSOLE" --days "$CDAYS" --band "$CBAND" --site "$CSITE"
fi
UOS="$REPO/.claude/scripts/uos-mongo.sh"
arg="${1:?usage: optimize-radios.sh <site> [days] [band]  |  optimize-radios.sh --console <name> [days] [band]}"; DAYS="${2:-14}"; BAND="${3:-ng}"
ROAM_MIN="${ROAM_MIN:-4}"; CAP="${CAP:-85}"; ZPCT="${ZONE_DISABLE_PCT:-40}"
REDUN_NG="${REDUN_NG:-2}"; REDUN_OTHER="${REDUN_OTHER:-1}"
if [[ "$arg" =~ ^[0-9a-f]{24}$ ]]; then SITE="$arg"; else
  SITE="$(bash "$UOS" --sites 2>/dev/null | grep -vi 'pq.html' | grep -i "$arg" | awk '{print $1}' | head -1)"
  [ -n "$SITE" ] || { echo "[ERROR] no site matching '$arg'"; exit 1; }
fi
case "$BAND" in ng) RSSI_OK=-68; REDUN=$REDUN_NG;; na) RSSI_OK=-72; REDUN=$REDUN_OTHER;; 6e) RSSI_OK=-75; REDUN=$REDUN_OTHER;; *) echo "band must be ng|na|6e"; exit 1;; esac
# Coverage-adjacency source: the measured AP-to-AP SNR matrix from neighbor-collect.sh (NEIGHBOR_JSON)
# is preferred — it's dense + accurate. The roam graph is the fallback but is sparse in sites with
# stationary clients (e.g. senior living), which is why disables came back 0 there. NBR_SNR_MIN = the
# SNR (dB) at which a managed-AP neighbor counts as real overlap (neighbor-collect default 20).
NBR_SNR_MIN="${NBR_SNR_MIN:-20}"; STRONG_RAW=""; ADJ_SRC="roam-graph (sparse w/ stationary clients)"
if [ -n "${NEIGHBOR_JSON:-}" ] && [ -f "${NEIGHBOR_JSON:-}" ]; then
  # Precompute bidirectional strong-overlap NAME pairs HERE (not inside mongo JS — a large embedded
  # object literal crashes the old mongo shell). Emit a compact ';'-joined, tab-internal flat string.
  STRONG_RAW="$(python - "$NEIGHBOR_JSON" "$NBR_SNR_MIN" <<'PY'
import json,sys
nbr=json.load(open(sys.argv[1])); MIN=int(sys.argv[2])
def ms(a,b):  # max SNR a->b over any band
    o=nbr.get(a,{}); return max([o.get(bd,{}).get(b,-1) for bd in ('2.4','5','6')]+[-1])
aps=list(nbr); out=[]
for i,a in enumerate(aps):
    for b in aps[i+1:]:
        if ms(a,b)>=MIN and ms(b,a)>=MIN:   # bidirectional overlap
            out.append(a.replace('"','').replace('\\','')+'\t'+b.replace('"','').replace('\\',''))
print(';'.join(out))
PY
)"
  ADJ_SRC="neighbor SNR matrix /proc/ui_neighbor (SNR>=$NBR_SNR_MIN)"
fi
echo "[INFO] site=$SITE band=$BAND window=${DAYS}d  rssi>=$RSSI_OK roam>=$ROAM_MIN cap=${CAP}% need_neighbors=$REDUN zone_cap=${ZPCT}%"
echo "[INFO] coverage adjacency source: $ADJ_SRC"

cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need'
var SITE='$SITE',DAYS=$DAYS,BAND='$BAND',RSSI_OK=$RSSI_OK,ROAM_MIN=$ROAM_MIN,CAP=$CAP,ZPCT=$ZPCT,REDUN=$REDUN;
var STRONG_RAW="$STRONG_RAW";   // ';'-joined "nameA<tab>nameB" bidirectional strong-overlap pairs, or ""
var ace=db.getSiblingDB('ace'),st=db.getSiblingDB('ace_stat');
var since=new Date().getTime()-DAYS*86400000;
// identity + zone
var name={},zone={};
ace.device.find({site_id:SITE,type:'uap'},{mac:1,name:1}).forEach(function(a){
  name[a.mac]=a.name||a.mac;
  var fz=String(a.name||'').match(/(\d)(?:st|nd|rd|th)\s*floor/i), rm=String(a.name||'').match(/\b(\d)\d{2}\b/);
  zone[a.mac]=fz?('Floor '+fz[1]):(rm?('Floor '+rm[1]):'misc');
});
// airtime profile (avg + peak) for the band
var prof={};
st.stat_hourly.find({o:'ap',site_id:SITE,time:{\$gte:since}}).forEach(function(d){
  var ap=d.ap; if(!ap||name[ap]===undefined) return;
  var cu=d[BAND+'-cu_total'],intf=d[BAND+'-cu_interf'],self=(d[BAND+'-cu_self_rx']||0)+(d[BAND+'-cu_self_tx']||0),
      sta=d[BAND+'-num_sta'],retr=d[BAND+'-tx_retries'],att=d[BAND+'-wifi_tx_attempts'],sat=d[BAND+'-satisfaction'];
  if(cu==null&&intf==null) return;
  if(!prof[ap])prof[ap]={cu:0,intf:0,self:0,sta:0,staPk:0,retr:0,att:0,sat:0,satN:0,n:0};
  var p=prof[ap]; p.cu+=(cu||0);p.intf+=(intf||0);p.self+=self;p.sta+=(sta||0);p.staPk=Math.max(p.staPk,sta||0);
  p.retr+=(retr||0);p.att+=(att||0); if(typeof sat==='number'){p.sat+=sat;p.satN++;} p.n++;
});
// tx_retries is a COUNT, not a %, so normalize by tx attempts; satisfaction averaged over its own samples.
for(var k in prof){var p=prof[k];['cu','intf','self','sta'].forEach(function(f){p[f]/=p.n;});
  p.retrPct=p.att>0?Math.min(100,100*p.retr/p.att):0; p.sat=p.satN>0?p.sat/p.satN:100; void 0;}  // void 0: stop the mongo REPL echoing this for-loop's numeric completion value
// directional roam edges + RSSI
var dir={}; // "A>B"->count ; rs["A>B"]->[rssi to B]
var rs={};
st.wifi_connectivity_event.find({site_id:SITE,time:{\$gte:since}},{from_endpoint:1,to_endpoint:1}).forEach(function(e){
  var A=e.from_endpoint&&e.from_endpoint.mac,B=e.to_endpoint&&e.to_endpoint.mac; if(!A||!B||A===B)return;
  var key=A+'>'+B; dir[key]=(dir[key]||0)+1; var r=e.to_endpoint&&e.to_endpoint.rssi;
  if(typeof r==='number'){(rs[key]=rs[key]||[]).push(r);}
});
function p25(a){if(!a||!a.length)return -127;a=a.slice().sort(function(x,y){return x-y;});return a[Math.floor(a.length*0.25)];}
// strong bidirectional neighbors per AP (band-agnostic adjacency; physical overlap is band-independent)
var strong={};
Object.keys(prof).forEach(function(A){ strong[A]={};
  Object.keys(prof).forEach(function(B){ if(A===B)return;
    var ab=dir[A+'>'+B]||0, ba=dir[B+'>'+A]||0;
    if(Math.min(ab,ba)>=ROAM_MIN && p25((rs[A+'>'+B]||[]).concat(rs[B+'>'+A]||[]))>=RSSI_OK) strong[A][B]=1;
  });
});
// PREFERRED: rebuild adjacency from the measured AP-to-AP SNR matrix (neighbor-collect / /proc/ui_neighbor)
// when provided — denser + accurate. Overlap is bidirectional: A hears B AND B hears A, both >= NBR_SNR_MIN
// on any band (physical overlap is band-independent). This is what makes DISABLE decisions data-backed in
// sites where the roam graph is too sparse (stationary clients).
if(STRONG_RAW){
  var nm2mac={}; for(var m in name){ nm2mac[name[m]]=m; }
  Object.keys(prof).forEach(function(A){ strong[A]={}; });
  STRONG_RAW.split(';').forEach(function(pr){ if(!pr)return; var t=pr.split('\t');
    var A=nm2mac[t[0]], B=nm2mac[t[1]];
    if(A&&B&&prof[A]&&prof[B]){ strong[A][B]=1; strong[B][A]=1; }   // bidirectional overlap proven by SNR
  });
}
// greedy capacity-aware disable
var aps=Object.keys(prof), active={}; aps.forEach(function(a){active[a]=true;});
var projCu={}; aps.forEach(function(a){projCu[a]=prof[a].cu;});           // projected utilization (grows as we shift load)
var zoneTot={},zoneDis={}; aps.forEach(function(a){zoneTot[zone[a]]=(zoneTot[zone[a]]||0)+1;});
function activeStrong(a){var o=strong[a]||{},r=[];for(var n in o)if(active[n])r.push(n);return r;}
function absorbers(a){ // active strong neighbors that can absorb a's cu_self under CAP
  return activeStrong(a).filter(function(n){return projCu[n]+prof[a].self<=CAP;});
}
function soleNeighborOf(a){ for(var x in active){if(!active[x]||x===a)continue; if(!(strong[x]||{})[a])continue;
  var others=0,o=strong[x]||{}; for(var n in o)if(n!==a&&active[n])others++; if(others===0)return true;} return false; }
var disabled=[];
var go=true;
while(go){ go=false; var best=null,bestBen=-1;
  aps.forEach(function(a){ if(!active[a])return; var p=prof[a];
    if(activeStrong(a).length<REDUN) return;                  // not enough overlap -> keep
    if(absorbers(a).length<1) return;                         // no neighbor with headroom -> power-down, not disable
    if(soleNeighborOf(a)) return;                             // protect sole coverage
    if((zoneDis[zone[a]]||0)+1 > Math.floor(zoneTot[zone[a]]*ZPCT/100)) return; // zone cap
    var benefit=p.intf + 0.5*p.retrPct;                       // remove interference + thrashing; cu_self transfers, not removed
    if(benefit>bestBen){bestBen=benefit;best=a;}
  });
  if(best){ var abs=absorbers(best); active[best]=false; zoneDis[zone[best]]=(zoneDis[zone[best]]||0)+1;
    // shift best's own-load onto its absorbers (split)
    var share=prof[best].self/abs.length; abs.forEach(function(n){projCu[n]+=share;});
    disabled.push({ap:best,cover:abs.map(function(n){return name[n];})}); go=true; }
}
// classify the rest
// POWER-DOWN is always coverage-safe (keeps the BSSID, just shrinks the cell), so recommend it
// for every saturated/contended radio regardless of whether we have roam-neighbor evidence. DISABLE
// (above) is the only action that needs positive coverage evidence, hence its strict gate.
var powerdown=[],keep=[];
aps.forEach(function(a){ if(!active[a])return; var p=prof[a];
  if(p.n<3 || (p.cu<5 && p.intf<5)){ keep.push({ap:a,why:'idle/off'}); return; }            // already-off / idle
  if(p.cu>=40 || p.intf>=35 || p.retrPct>=15 || p.sat<80) powerdown.push(a);                 // saturated/contended/thrashing -> shrink cell
  else keep.push({ap:a,why:'low-util'});
});
function fmt(a){var p=prof[a];return name[a]+"  cu="+p.cu.toFixed(0)+"% interf="+p.intf.toFixed(0)+"% self="+p.self.toFixed(0)+"% clients(avg/pk)="+p.sta.toFixed(1)+"/"+p.staPk.toFixed(0)+" retr="+p.retrPct.toFixed(0)+"% sat="+p.sat.toFixed(0);}
function byZone(list,kf){var z={};list.forEach(function(x){var a=kf(x);(z[zone[a]]=z[zone[a]]||[]).push(x);});return z;}
print("==== PLAN band="+BAND+"  (APs="+aps.length+")  POWER-DOWN(first)="+powerdown.length+"  DISABLE(after)="+disabled.length+"  KEEP="+keep.length+" ====");
var save=0;disabled.forEach(function(d){save+=prof[d.ap].intf;});
print("Phase A: POWER-DOWN the busy/thrashing radios to ~Low (smaller cells cut mutual cu_interf for the whole zone). Do this FIRST.");
var pz=byZone(powerdown,function(a){return a;});
Object.keys(pz).sort().forEach(function(z){print(" ["+z+"] "+pz[z].length);pz[z].slice(0,6).forEach(function(a){print("   "+fmt(a));}); if(pz[z].length>6)print("   ...(+"+(pz[z].length-6)+")");});
print("\nPhase B: re-measure (live-stats.sh) after power-down settles - cu_interf should drop, headroom appears.");
print("\nPhase C: DISABLE these only-when-redundant radios (each has >="+REDUN+" bidirectional good neighbors WITH headroom to absorb its load). Est. interference airtime removed: "+save.toFixed(0)+".");
if(!disabled.length) print("   (none clear the capacity+coverage bar yet - expected when every neighbor is saturated; revisit after Phase A.)");
var dz=byZone(disabled,function(d){return d.ap;});
Object.keys(dz).sort().forEach(function(z){print(" ["+z+"]");dz[z].forEach(function(d){print("   "+fmt(d.ap)+"  -> covered by: "+d.cover.slice(0,3).join(', '));});});
print("\nKEEP: "+keep.length+" radios (isolated-essential or already efficient). Apply per ZONE; never >"+ZPCT+"% disabled/zone; validate before+after.");
JS
