#!/usr/bin/env bash
# gw-sitemanager.sh -- UniFi Site Manager CLOUD API backend for the unifi-wifi skill.
#
# Reaches EVERY ACG UniFi console remotely via api.ui.com (Ubiquiti's cloud), with
# NO UOS server and NO local-network access. This is the "outside UOS" / alternate
# route -- use it for consoles that are NOT adopted into the UOS server (standalone
# UDMs like Brooklyn/Skybar), for fleet-wide overview, and for WAN/ISP telemetry.
#
# Auth: X-API-KEY header. Key is read from the vault (services/unifi-site-manager),
# or override with UNIFI_SM_KEY=... . Read-only.
#
# DATA DEPTH (empirically mapped 2026-06-17 -- see references/site-manager-api.md):
#   TIER 1 (Site Manager api.ui.com, the fleet/* + isp commands): inventory (hosts +
#     per-host devices), site health counts, gateway IPS posture, ISP/ASN, and WAN/ISP
#     time-series (latency/throughput/downtime 5m|1h). NOT per-radio/per-client.
#   TIER 2 (cloud CONNECTOR -> console LOCAL Network API, the `net` command): the SAME
#     ace_stat depth as the UOS Mongo path -- per-radio cu_total airtime, channel, width,
#     tx_power, num_sta, satisfaction; per-client rssi/signal/noise/satisfaction/rates.
#     Reached remotely via /v1/connector/consoles/<id>/proxy/network/... with this same
#     account key. == UOS PARITY for ANY console, no LAN/VPN/UOS server.
#
# Usage:
#   gw-sitemanager.sh fleet [filter]            # list all consoles (name, WAN IP, state, #devices)
#   gw-sitemanager.sh host <name|id>            # one console: identity + summary
#   gw-sitemanager.sh devices <name|id>         # device inventory for a console
#   gw-sitemanager.sh sites [filter]            # sites with health counts + ISP + IPS
#   gw-sitemanager.sh isp <name|id> [5m|1h] [N] # recent WAN/ISP metrics (default 12 periods)
#   gw-sitemanager.sh net <name> radios         # DEEP: per-AP airtime/channel/txpower (UOS parity)
#   gw-sitemanager.sh net <name> clients        # DEEP: per-client RSSI/signal/satisfaction
#   gw-sitemanager.sh net <name> devices        # DEEP: internal device list (#sta, uptime)
#   gw-sitemanager.sh net <name> raw <path> [--site <s>]  # proxy any /proxy/network/... path
#   gw-sitemanager.sh find <name>               # resolve a console name -> host id
#   gw-sitemanager.sh raw <path>                # GET an arbitrary api.ui.com path (escape hatch)
#
# Exit: 0 ok, 1 error/no-data, 2 usage.
set -uo pipefail
SELF="gw-sitemanager"
BASE="https://api.ui.com"

PY="$(command -v py 2>/dev/null || command -v python 2>/dev/null || command -v python3 2>/dev/null || true)"
[ -z "$PY" ] && { echo "[$SELF] python required" >&2; exit 1; }

REPO_ROOT="${CLAUDETOOLS_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
# Mandatory skill error logging (skill-creator rule): log GENUINE functional failures only.
logerr(){ bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "unifi-wifi/gw-sitemanager" "$1" --context "${2:-}" >/dev/null 2>&1 || true; }

# --- API key from vault (or env override) ---
KEY="${UNIFI_SM_KEY:-}"
if [ -z "$KEY" ]; then
  KEY="$(bash "$REPO_ROOT/.claude/scripts/vault.sh" get-field services/unifi-site-manager credentials.api_key 2>/dev/null | tr -d '\r\n')"
fi
[ -z "$KEY" ] && { echo "[$SELF] no API key (vault services/unifi-site-manager credentials.api_key, or set UNIFI_SM_KEY)" >&2; exit 1; }

api() {  # $1 = path -> raw JSON on stdout; non-200 -> empty + stderr note
  local path="$1" resp code body
  resp=$(curl -s -w "\n__C__%{http_code}" -H "X-API-KEY: $KEY" -H "Accept: application/json" "$BASE$path" 2>/dev/null)
  code=$(printf '%s' "$resp" | sed -n 's/.*__C__//p')
  body=$(printf '%s' "$resp" | sed 's/__C__[0-9]*$//')
  if [ "$code" != "200" ]; then echo "[$SELF] GET $path -> HTTP $code" >&2; logerr "Site Manager API call failed (HTTP $code)" "path=$path"; return 1; fi
  printf '%s' "$body"
}

MODE="${1:-}"; shift 2>/dev/null || true
[ -z "$MODE" ] && { echo "usage: $SELF {fleet|host|devices|sites|isp|find|raw} ..." >&2; exit 2; }

case "$MODE" in
  fleet|hosts)
    FILTER="${1:-}"
    HJSON="$(api /v1/hosts)" || exit 1
    DJSON="$(api /v1/devices)" || exit 1
    printf '%s\n\x1e%s' "$HJSON" "$DJSON" | "$PY" -c "
import json,sys
raw=sys.stdin.read().split('\x1e',1)
hosts=json.loads(raw[0]).get('data',[])
devs ={g['hostId']:g.get('devices',[]) for g in json.loads(raw[1]).get('data',[])}
flt='''$FILTER'''.strip().lower()
rows=[]
for h in hosts:
    name=((h.get('reportedState') or {}).get('name')) or h.get('id','')
    if flt and flt not in name.lower(): continue
    dl=devs.get(h['id'],[])
    online=sum(1 for d in dl if (d.get('status')=='online'))
    cons=next((d for d in dl if d.get('isConsole')), None)
    model=(cons or {}).get('model') or '?'
    rows.append((name, h.get('ipAddress') or '-', model, len(dl), online, 'owner' if h.get('owner') else 'shared'))
rows.sort(key=lambda r:r[0].lower())
print('%-30s %-16s %-12s %5s %7s %s' % ('CONSOLE','WAN IP','MODEL','DEVS','ONLINE','OWN'))
for r in rows: print('%-30s %-16s %-12s %5d %7d %s' % (r[0][:30], r[1], str(r[2])[:12], r[3], r[4], r[5]))
print('\n%d console(s)%s' % (len(rows), (' matching \"'+flt+'\"') if flt else ''))
"
    ;;

  find|host)
    [ -z "${1:-}" ] && { echo "usage: $SELF $MODE <name|id>" >&2; exit 2; }
    Q="$1"
    api /v1/hosts | "$PY" -c "
import json,sys
q='''$Q'''.strip().lower()
hosts=json.loads(sys.stdin.read()).get('data',[])
def nm(h): return ((h.get('reportedState') or {}).get('name')) or ''
m=[h for h in hosts if h.get('id','').lower()==q or q in nm(h).lower()]
if not m: print('[no match for \"%s\"]'%q); sys.exit(1)
for h in m[:8]:
    rs=h.get('reportedState') or {}
    print('name      :', nm(h))
    print('id        :', h.get('id'))
    print('wan ip    :', h.get('ipAddress'))
    print('type/owner:', h.get('type'), '/', 'owner' if h.get('owner') else 'shared')
    print('hardwareId:', h.get('hardwareId'))
    print('registered:', h.get('registrationTime'),' last-change:', h.get('lastConnectionStateChange'))
    ctrls=rs.get('controllers') or (rs.get('userData') or {}).get('controllers') or []
    if isinstance(ctrls,list) and ctrls:
        print('controllers:')
        for c in ctrls:
            if not isinstance(c,dict): continue
            integ='  [Integration API: '+ (c.get('integrationApis')[0].get('apiDocsLocation','') if c.get('integrationApis') else '') +']' if c.get('integrationApis') else ''
            print('   %-11s %-9s v%-10s%s' % (c.get('name','?'), c.get('state',c.get('status','?')), (c.get('version') or '-'), integ))
    if len(m)>1: print('---')
" || exit 1
    ;;

  devices)
    [ -z "${1:-}" ] && { echo "usage: $SELF devices <name|id>" >&2; exit 2; }
    Q="$1"
    HID="$(api /v1/hosts | "$PY" -c "
import json,sys
q='''$Q'''.strip().lower()
hosts=json.loads(sys.stdin.read()).get('data',[])
def nm(h): return ((h.get('reportedState') or {}).get('name')) or ''
m=[h for h in hosts if h.get('id','').lower()==q or q in nm(h).lower()]
print(m[0]['id'] if m else '')
")"
    [ -z "$HID" ] && { echo "[$SELF] no console matching '$Q'" >&2; exit 1; }
    api /v1/devices | HID="$HID" "$PY" -c "
import json,sys,os
hid=os.environ['HID']
g=next((x for x in json.loads(sys.stdin.read()).get('data',[]) if x.get('hostId')==hid), None)
if not g: print('[no device data]'); sys.exit(1)
ds=sorted(g.get('devices',[]), key=lambda d:(d.get('productLine',''), d.get('name') or ''))
print('%-26s %-20s %-15s %-8s %-9s %s' % ('NAME','MODEL','IP','STATE','FW','LINE'))
for d in ds:
    print('%-26s %-20s %-15s %-8s %-9s %s' % ((d.get('name') or '?')[:26],(d.get('model') or '?')[:20],
        d.get('ip') or '-', d.get('status') or '?', str(d.get('version') or '-')[:9], d.get('productLine') or ''))
print('\n%s: %d devices, %d online' % (g.get('hostName'), len(ds), sum(1 for d in ds if d.get('status')=='online')))
" || exit 1
    ;;

  sites)
    FILTER="${1:-}"
    HJSON="$(api /v1/hosts)" || exit 1
    SJSON="$(api /v1/sites)" || exit 1
    printf '%s\n\x1e%s' "$HJSON" "$SJSON" | FLT="${FILTER}" "$PY" -c "
import json,sys,os
raw=sys.stdin.read().split('\x1e',1)
hosts={h['id']:((h.get('reportedState') or {}).get('name') or h.get('id','')) for h in json.loads(raw[0]).get('data',[])}
sites=json.loads(raw[1]).get('data',[])
flt=os.environ.get('FLT','').strip().lower()
print('%-22s %-16s %4s %4s %6s %6s %-5s %s' % ('CONSOLE','SITE','DEV','OFF','WIFIcl','WIREcl','IPS','ISP'))
n=0
for s in sorted(sites, key=lambda s: hosts.get(s.get('hostId',''),'').lower()):
    cons=hosts.get(s.get('hostId',''),'?')
    meta=s.get('meta') or {}; sname=meta.get('desc') or meta.get('name') or ''
    if flt and flt not in cons.lower() and flt not in sname.lower(): continue
    st=s.get('statistics') or {}; c=st.get('counts') or {}; gw=st.get('gateway') or {}; isp=st.get('ispInfo') or {}
    n+=1
    print('%-22s %-16s %4s %4s %6s %6s %-5s %s' % (cons[:22], sname[:16],
        c.get('totalDevice','-'), c.get('offlineDevice','-'), c.get('wifiClient','-'), c.get('wiredClient','-'),
        (gw.get('ipsMode') or '-')[:5], (isp.get('name') or '-')))
print('\n%d site(s)%s' % (n,(' matching \"'+flt+'\"') if flt else ''))
" || exit 1
    ;;

  isp)
    [ -z "${1:-}" ] && { echo "usage: $SELF isp <name|id> [5m|1h] [N]" >&2; exit 2; }
    Q="$1"; GRAN="${2:-5m}"; N="${3:-12}"
    case "$GRAN" in 5m|1h) ;; *) echo "[$SELF] granularity must be 5m or 1h" >&2; exit 2;; esac
    HID="$(api /v1/hosts | "$PY" -c "
import json,sys
q='''$Q'''.strip().lower()
hosts=json.loads(sys.stdin.read()).get('data',[])
def nm(h): return ((h.get('reportedState') or {}).get('name')) or ''
m=[h for h in hosts if h.get('id','').lower()==q or q in nm(h).lower()]
print(m[0]['id'] if m else '')
")"
    [ -z "$HID" ] && { echo "[$SELF] no console matching '$Q'" >&2; exit 1; }
    api "/v1/isp-metrics/$GRAN" | HID="$HID" N="$N" GRAN="$GRAN" "$PY" -c "
import json,sys,os
hid=os.environ['HID']; N=int(os.environ['N'])
items=[x for x in json.loads(sys.stdin.read()).get('data',[]) if x.get('hostId')==hid]
if not items: print('[no ISP metrics for this console]'); sys.exit(1)
print('WAN/ISP metrics (%s, last %d periods)' % (os.environ['GRAN'], N))
for it in items:
    per=it.get('periods',[])[-N:]
    print('  site %s -- %d periods total' % (it.get('siteId','?'), len(it.get('periods',[]))))
    print('    %-22s %7s %9s %9s %5s %5s' % ('TIME(UTC)','lat(ms)','dl(Mbps)','ul(Mbps)','loss','down'))
    for p in per:
        w=((p.get('data') or {}).get('wan')) or {}
        t=p.get('metricTime') or '?'
        dl=w.get('download_kbps'); ul=w.get('upload_kbps')
        print('    %-22s %7s %9s %9s %5s %5s' % (str(t)[:22], w.get('avgLatency','-'),
            round(dl/1000,1) if isinstance(dl,(int,float)) else '-',
            round(ul/1000,1) if isinstance(ul,(int,float)) else '-',
            w.get('packetLoss', w.get('packet_loss','-')), w.get('downtime','-')))
" || exit 1
    ;;

  net)
    # DEEP tier (UOS PARITY): proxy the console's LOCAL Network API through the cloud
    # connector -- api.ui.com/v1/connector/consoles/<id>/proxy/network/... -- using the
    # account key. The internal /api/s/<site>/stat/* endpoints return the SAME ace_stat
    # depth the UOS Mongo path gives (per-radio cu_total airtime, per-client rssi/sat),
    # for ANY console remotely, no LAN/VPN/UOS. (Confirmed empirically 2026-06-17.)
    [ -z "${1:-}" ] && { echo "usage: $SELF net <console> {radios|clients|devices|raw <path>} [--site <name>]" >&2; exit 2; }
    Q="$1"; SUB="${2:-radios}"; SITE="default"; RAWPATH=""
    shift 2 2>/dev/null || shift $# 2>/dev/null || true
    while [ $# -gt 0 ]; do case "$1" in --site) SITE="${2:-default}"; shift 2 2>/dev/null || shift;; *) RAWPATH="$1"; shift;; esac; done
    HID="$(api /v1/hosts | "$PY" -c "
import json,sys
q='''$Q'''.strip().lower()
hosts=json.loads(sys.stdin.read()).get('data',[])
def nm(h): return ((h.get('reportedState') or {}).get('name')) or ''
m=[h for h in hosts if h.get('id','').lower()==q or q in nm(h).lower()]
print(m[0]['id'] if m else '')
")"
    [ -z "$HID" ] && { echo "[$SELF] no console matching '$Q'" >&2; exit 1; }
    CB="/v1/connector/consoles/$HID/proxy/network"
    case "$SUB" in
      radios)
        api "$CB/api/s/$SITE/stat/device" | "$PY" -c "
import json,sys
B={'ng':'2.4','na':'5','6e':'6','6g':'6'}
d=json.loads(sys.stdin.read()).get('data',[])
aps=[x for x in d if x.get('type')=='uap']
print('%-22s %-4s %-5s %-3s %6s %4s %6s %5s %6s' % ('AP','BAND','CHAN','BW','UTIL%','STA','TXPWR','SAT','RETRY%'))
for ap in sorted(aps,key=lambda a:a.get('name') or ''):
    for r in (ap.get('radio_table_stats') or []):
        print('%-22s %-4s %-5s %-3s %6s %4s %6s %5s %6s' % ((ap.get('name') or '?')[:22], B.get(r.get('radio'),str(r.get('radio'))),
            r.get('channel'), r.get('bw'), r.get('cu_total'), r.get('num_sta'), r.get('tx_power'), r.get('satisfaction'), r.get('tx_retries_pct')))
print('\n%d AP(s) on site \"$SITE\"' % len(aps))
" || exit 1 ;;
      clients|sta)
        api "$CB/api/s/$SITE/stat/sta" | "$PY" -c "
import json,sys
cl=json.loads(sys.stdin.read()).get('data',[])
wifi=[c for c in cl if not c.get('is_wired')]
print('%-20s %5s %7s %6s %4s %4s %-15s %s' % ('CLIENT','RSSI','SIGNAL','NOISE','CH','SAT','RATE kbps t/r','SSID'))
for c in sorted(wifi,key=lambda c:-(c.get('rssi') or 0)):
    nm=c.get('hostname') or c.get('name') or c.get('mac')
    print('%-20s %5s %7s %6s %4s %4s %-15s %s' % (str(nm)[:20], c.get('rssi'), c.get('signal'), c.get('noise'),
        c.get('channel'), c.get('satisfaction'), str(c.get('tx_rate','-'))+'/'+str(c.get('rx_rate','-')), c.get('essid','-')))
print('\n%d wireless of %d total client(s)' % (len(wifi), len(cl)))
" || exit 1 ;;
      devices)
        api "$CB/api/s/$SITE/stat/device" | "$PY" -c "
import json,sys
d=json.loads(sys.stdin.read()).get('data',[])
print('%-22s %-6s %-15s %5s %9s' % ('NAME','TYPE','IP','#STA','UPTIME(h)'))
for x in sorted(d,key=lambda a:(a.get('type',''),a.get('name') or '')):
    print('%-22s %-6s %-15s %5s %9s' % ((x.get('name') or '?')[:22], x.get('type'), x.get('ip','-'), x.get('num_sta','-'), round((x.get('uptime') or 0)/3600)))
" || exit 1 ;;
      raw)
        [ -z "$RAWPATH" ] && { echo "usage: $SELF net <console> raw <path under /proxy/network, e.g. /integration/v1/sites>" >&2; exit 2; }
        api "$CB$RAWPATH" | "$PY" -c "import json,sys; print(json.dumps(json.load(sys.stdin),indent=1)[:5000])" || exit 1 ;;
      *) echo "[$SELF] net subcommand must be radios|clients|devices|raw" >&2; exit 2 ;;
    esac
    ;;

  raw)
    [ -z "${1:-}" ] && { echo "usage: $SELF raw <path>" >&2; exit 2; }
    api "$1" | "$PY" -c "import json,sys; print(json.dumps(json.load(sys.stdin),indent=1)[:4000])" || exit 1
    ;;

  *)
    echo "[$SELF] unknown mode '$MODE' (fleet|host|devices|sites|isp|find|raw)" >&2; exit 2 ;;
esac
