#!/usr/bin/env bash
# channel-plan.sh — compute (and optionally apply) a per-AP channel plan that minimizes co-channel
# overlap, using the measured AP-to-AP neighbor matrix (neighbor-collect) + per-channel busy% (survey).
#   ng (2.4): greedy graph-coloring into 1/6/11 so strong neighbors differ.
#   na (5GHz): per AP pick the NON-DFS channel with lowest cost = measured busy% + neighbor-collision
#              penalty (avoid DFS near military/airport radar; spreads APs apart).
# DRY-RUN default; --apply writes per-AP via the controller REST radio_table (gated, rollback saved).
# Inputs: NEIGHBOR_JSON (from neighbor-collect, required) + SURVEY_JSON (from survey-collect, for na).
#
# Usage:
#   NEIGHBOR_JSON=.claude/tmp/<site>-nbr.json SURVEY_JSON=.claude/tmp/<site>-survey.json \
#     bash .../channel-plan.sh <site> ng|na [--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/channel-plan" "$1" --context "${2:-}" >/dev/null 2>&1 || true; }
HOST="${UOS_HOST:-172.16.3.29}"; PORT="${UOS_HTTPS_PORT:-11443}"
SITEARG="${1:?usage: channel-plan.sh <site> <ng|na> [--apply]}"; BAND="${2:?band ng|na}"; APPLY=0
CHANS=""; DFSPOL=""
shift 2; while [ $# -gt 0 ]; do case "$1" in
  --apply) APPLY=1; shift;;
  --channels) CHANS="${2:-}"; shift 2;;          # explicit palette, e.g. 52,60,100,108,116,124,132,140
  --dfs) DFSPOL="${2:-}"; shift 2;;              # ok|avoid|only — data-driven policy (default: ok = all)
  *) shift;; esac; done
case "$BAND" in ng|na) ;; *) echo "band must be ng|na (channel planning is for 2.4/5GHz)"; exit 1;; esac
NEIGHBOR_JSON="${NEIGHBOR_JSON:-}"; SURVEY_JSON="${SURVEY_JSON:-}"; SNR_MIN="${NBR_SNR_MIN:-20}"
[ -n "$NEIGHBOR_JSON" ] && [ -f "$NEIGHBOR_JSON" ] || { echo "[ERROR] NEIGHBOR_JSON required (run neighbor-collect.sh with NBR_JSON=...)"; exit 1; }
U="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.username 2>/dev/null)"
P="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.password 2>/dev/null)"
[ -n "$U" ] && [ -n "$P" ] || { echo "[ERROR] no controller cred"; exit 1; }
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] channel-plan site=$SITE band=$BAND  mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)  (neighbor=$NEIGHBOR_JSON survey=${SURVEY_JSON:-none})"
export CP_SITE="$SITE" CP_BAND="$BAND" CP_APPLY="$APPLY" CP_NBR="$NEIGHBOR_JSON" CP_SURVEY="$SURVEY_JSON" CP_SNR="$SNR_MIN" CP_CHANS="$CHANS" CP_DFS="$DFSPOL" RW_U="$U" RW_P="$P" REPO
python - <<'PY'
import os,sys,json,ssl,urllib.request,http.cookiejar
band=os.environ['CP_BAND']; apply=os.environ['CP_APPLY']=='1'; SNR=int(os.environ['CP_SNR'])
nbr=json.load(open(os.environ['CP_NBR']))
survey=json.load(open(os.environ['CP_SURVEY'])) if os.environ.get('CP_SURVEY') and os.path.exists(os.environ['CP_SURVEY']) else {}
sb={'ng':'2.4','na':'5'}[band]
# Palette: data-driven. ng is always 1/6/11. na: --channels overrides; else --dfs policy decides.
# Default (na) is NOT a hardcoded non-DFS list anymore -- that baked-in bias picked the congested
# channels at Cascades. Run survey-report.py first and pass its suggested palette via --channels.
_chans=os.environ.get('CP_CHANS','').strip()
_dfs=os.environ.get('CP_DFS','').strip().lower()
NONDFS_NA=[36,40,44,48,149,153,157,161]; DFS_NA=[52,60,100,108,116,124,132,140]; ALL_NA=sorted(NONDFS_NA+DFS_NA)
if band=='ng':
    CH=[1,6,11]
elif _chans:
    CH=[int(x) for x in _chans.replace(' ','').split(',') if x]
elif _dfs=='only': CH=DFS_NA
elif _dfs=='avoid': CH=NONDFS_NA
else: CH=ALL_NA          # 'ok'/default: consider ALL 40MHz primaries; measured busy% chooses
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(m,p,b=None,csrf=None,wh=False):
    d=json.dumps(b).encode() if b is not None else None
    r=urllib.request.Request(base+p,data=d,method=m);r.add_header('Content-Type','application/json')
    if csrf:r.add_header('X-CSRF-Token',csrf)
    x=op.open(r,timeout=20);return (x.read().decode('utf-8','replace'),x.headers) if wh else x.read().decode('utf-8','replace')
_,hd=call('POST','/api/auth/login',{'username':os.environ['RW_U'],'password':os.environ['RW_P']},wh=True)
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['CP_SITE']),None)
devs=json.loads(call('GET',f'/proxy/network/api/s/{short}/stat/device')).get('data',[])
# current channel per AP (by name) + name->(mac,_id,radio_table)
cur={}; meta={}
for d in devs:
    if d.get('type')!='uap' or d.get('state')!=1: continue
    nm=d.get('name') or d.get('mac'); meta[nm]=d
    for r in d.get('radio_table_stats',[]):
        if r.get('radio')==band:
            try: cur[nm]=int(r.get('channel'))
            except: cur[nm]=None
# adjacency: strong bidirectional neighbors (union over bands), by name
def msnr(a,b):
    o=nbr.get(a,{});
    return max([o.get(x,{}).get(b,-1) for x in ('2.4','5','6')]+[-1])
aps=[a for a in cur if a in nbr]
adj={a:set() for a in aps}
for a in aps:
    for b in aps:
        if a!=b and msnr(a,b)>=SNR and msnr(b,a)>=SNR: adj[a].add(b)
order=sorted(aps,key=lambda a:-len(adj[a]))   # most-constrained first
plan={}
def busy(ap,ch):
    try: return survey.get(ap,{}).get(sb,{}).get(str(ch),50)
    except: return 50
from collections import Counter
def cost(a,ch,pl):
    coll=sum(1 for n in adj[a] if pl.get(n)==ch)             # strong-neighbor co-channel (dominant)
    load=Counter(pl.values()).get(ch,0)                      # balance APs across the palette
    bz=busy(a,ch) if band=='na' else 0                       # measured congestion (na)
    return coll*1000 + load*10 + bz
# greedy: most-constrained first
for a in order:
    plan[a]=min(CH,key=lambda ch:cost(a,ch,plan))
# local-search: repeatedly move the worst-collision AP to its best channel until stable
for _ in range(8000):
    worst=max(aps,key=lambda a:sum(1 for n in adj[a] if plan.get(n)==plan.get(a)))
    cur_c=sum(1 for n in adj[worst] if plan.get(n)==plan.get(worst))
    if cur_c==0: break
    alt=min(CH,key=lambda ch:cost(worst,ch,plan))
    if sum(1 for n in adj[worst] if plan.get(n)==alt) < cur_c: plan[worst]=alt
    else: break
changes=[(a,cur[a],plan[a]) for a in sorted(plan) if cur.get(a)!=plan[a]]
print(f"\n==== CHANNEL PLAN band={band}  ({len(aps)} APs, {len(changes)} would change) ====")
_pal='explicit --channels' if _chans else (_dfs+' policy' if _dfs else 'data-driven (all 40MHz primaries; busy% chooses)')
print(f"allowed channels: {CH}  [{_pal}]")
if band=='na' and not _chans:
    print("  TIP: run survey-report.py on the SURVEY_JSON first; pass its suggested clean palette via --channels.")
for a,c0,c1 in changes[:60]:
    print(f"  {a:<22} {str(c0):>4} -> {c1}   (strong-neighbor adj={len(adj[a])})")
if len(changes)>60: print(f"  ...(+{len(changes)-60} more)")
# collision check after plan
post=sum(1 for a in aps for n in adj[a] if plan.get(n)==plan.get(a))//2
pre =sum(1 for a in aps for n in adj[a] if cur.get(n)==cur.get(a) and cur.get(a))//2
print(f"\nstrong-neighbor co-channel pairs: before={pre}  after={post}")
if not apply:
    print("\n[dry-run] no changes. Add --apply to write per-AP channels (gated, rollback saved).")
    sys.exit(0)
# APPLY: per changed AP, set radio_table channel
roll=[];done=0;fail=0
for a,c0,c1 in changes:
    d=meta[a]; rt=d.get('radio_table') or []
    for r in rt:
        if r.get('radio')==band: r['channel']=c1
    try:
        call('PUT',f"/proxy/network/api/s/{short}/rest/device/{d['_id']}",{'radio_table':rt},csrf=csrf)
        roll.append({'name':a,'band':band,'old':c0}); done+=1; print(f"  [ok] {a} {band} -> ch{c1}")
    except Exception as e: fail+=1; print(f"  [FAIL] {a}: {e}")
rp=os.path.join(os.environ.get('REPO','.'),'.claude','tmp',f"chanplan-rollback-{short}-{band}.json")
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: {rp}")
print("[validate] re-run survey-collect / watch-ap after settle; roll out per-zone in practice.")
PY
rc=$?
[ "$rc" -ne 0 ] && logerr "channel-plan REST call failed" "site=$SITE band=$BAND rc=$rc"
exit $rc
