#!/usr/bin/env bash
# model-rank.sh — rank AP radios as airtime-reduction candidates from ACCUMULATED history.
# Data (all from ace_stat, already collected by the controller — no new collector needed):
#   - airtime/interference: stat_hourly (o:'ap')  <band>-cu_total, <band>-cu_interf, <band>-num_sta
#   - coverage overlap:     wifi_connectivity_event  (client roams between AP pairs)
# Per band (ng/na/6e). A radio is a strong DISABLE candidate when it carries high interference
# airtime AND its clients heavily roam to other APs (redundant coverage); a POWER-DOWN candidate
# when busy/interfering but with less roam redundancy. This is a v1 ranker, not the final greedy
# optimizer — see references/interference-model.md.
#
# Usage: bash .claude/skills/unifi-wifi/scripts/model-rank.sh <site-name|site_id> [days=7] [band=ng|na|6e|all]
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.
# (Pure Mongo read/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/model-rank" "$1" --context "${2:-}" >/dev/null 2>&1 || true; }
# Cloud connector path (non-UOS console): `model-rank.sh --console "<name>" [days] [band]` routes to
# rf-analyze.py (Site Manager API; see references/site-manager-api.md). The UOS Mongo path below is unchanged.
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: model-rank.sh --console <name> [--site <s>] [days] [band]"; exit 2; }
  CDAYS="${CPOS[0]:-7}"; 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" rank --console "$CONSOLE" --days "$CDAYS" --band "$CBAND" --site "$CSITE"
fi
UOS="$REPO/.claude/scripts/uos-mongo.sh"
arg="${1:?usage: model-rank.sh <site> [days] [band]  |  model-rank.sh --console <name> [days] [band]}"; DAYS="${2:-7}"; BAND="${3:-ng}"
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
echo "[INFO] site=$SITE  window=${DAYS}d  band=$BAND"

cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need'
var SITE='$SITE', DAYS=$DAYS, BAND='$BAND';
var ace=db.getSiblingDB('ace'), st=db.getSiblingDB('ace_stat');
var since = new Date().getTime() - DAYS*86400000;
var bands = (BAND=='all') ? ['ng','na','6e'] : [BAND];
// ap mac -> name
var name={}; ace.device.find({site_id:SITE,type:'uap'},{mac:1,name:1}).forEach(function(a){name[a.mac]=a.name||a.mac;});
// roam volume per AP (coverage-overlap proxy: high roam = redundant neighbors exist)
var roam={};
st.wifi_connectivity_event.find({site_id:SITE, time:{\$gte:since}},{from_endpoint:1,to_endpoint:1}).forEach(function(e){
  [e.from_endpoint&&e.from_endpoint.mac, e.to_endpoint&&e.to_endpoint.mac].forEach(function(m){ if(m) roam[m]=(roam[m]||0)+1; });
});
// airtime profile per AP per band from stat_hourly
var prof={};
st.stat_hourly.find({o:'ap', site_id:SITE, time:{\$gte:since}}).forEach(function(d){
  var ap=d.ap; if(!ap) return; if(!prof[ap])prof[ap]={};
  bands.forEach(function(b){
    var cu=d[b+'-cu_total'], intf=d[b+'-cu_interf'], sta=d[b+'-num_sta'];
    if(cu==null && intf==null) return;
    if(!prof[ap][b])prof[ap][b]={cu:0,intf:0,sta:0,n:0};
    var p=prof[ap][b]; p.cu+=(cu||0); p.intf+=(intf||0); p.sta+=(sta||0); p.n++;
  });
});
bands.forEach(function(b){
  var rows=[];
  for(var ap in prof){ var p=prof[ap][b]; if(!p||!p.n) continue;
    var avgCu=p.cu/p.n, avgIntf=p.intf/p.n, avgSta=p.sta/p.n, rm=roam[ap]||0;
    // score: airtime pressure (cu+interf) weighted, * redundancy(roam) / (load+1)
    var score = (avgCu + avgIntf) * Math.log(1+rm) / (1+avgSta);
    rows.push({ap:ap, name:name[ap]||ap, cu:avgCu, intf:avgIntf, sta:avgSta, roam:rm, score:score});
  }
  rows.sort(function(a,b){return b.score-a.score;});
  print("\\n==== band="+b+"  top airtime-reduction candidates (disable/power-down) ====");
  print("  rank  AP                         cu%   interf%  ~clients  roams   score   hint");
  rows.slice(0,15).forEach(function(r,i){
    var hint = (r.roam>50 && r.sta<3) ? "DISABLE (redundant, low load)" : (r.cu+r.intf>40 ? "POWER-DOWN (busy)" : "review");
    print("  "+(i+1)+"\\t"+(r.name.substring(0,24)+"                        ").substring(0,24)+" "+r.cu.toFixed(0)+"\\t"+r.intf.toFixed(0)+"\\t"+r.sta.toFixed(1)+"\\t"+r.roam+"\\t"+r.score.toFixed(1)+"\\t"+hint);
  });
  print("  (APs profiled on "+b+": "+rows.length+")");
});
print("\\n[note] v1 heuristic: score = (cu_total + cu_interf) * log(1+roams) / (clients+1). High = busy+interfered AND clients have somewhere else to roam = safe to shrink/disable. Validate per-zone before applying. Full greedy coverage-safe optimizer = v2 (interference-model.md).");
JS
