#!/usr/bin/env python3
"""
uisp — CLI for UISP at $UISP_URL (default https://uisp.vntx.net).

Auth: export UISP_KEY=<x-auth-token from UISP user settings>.

Subcommands:
  list     [--unauthorized] [--upgradable] [--json] [--raw]
  approve  <id>... | --all  [--site vntx] [--port 443] [--dry-run]
  upgrade  <id>... | --all  [--yes] [--force]
  prune    [--days 1] [--yes]   delete devices offline longer than N days
"""
from __future__ import annotations

import argparse
import datetime as dt
import json
import os
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter
from typing import Any, Iterable

DEFAULT_BASE = os.environ.get("UISP_URL", "https://uisp.vntx.net").rstrip("/")
TOKEN = os.environ.get("UISP_KEY") or os.environ.get("UISP_TOKEN")

# Ordered list — first cred that lets UISP adopt a device wins.
STANDARD_CREDS = [
    ("ubnt", "fngckewl"),
    ("ubnt", "vntx1830"),
    ("ubnt", "Vntx1830"),
    ("ubnt", "vntx1830vntx"),
    ("ubnt", "H8xd9tkryg"),
    ("ubnt", "h8xd9tkryg"),
]

# discovery.status values that mean "trying again with a new password might help".
RETRYABLE_DISCOVERY_STATUSES = {
    "authentication-failed",
    "authenticating",
    "starting",
}


def die(msg: str, code: int = 1) -> None:
    print(f"error: {msg}", file=sys.stderr)
    sys.exit(code)


def api(method: str, path: str, body: Any = None) -> Any:
    if not TOKEN:
        die("UISP_KEY env var is not set")
    url = f"{DEFAULT_BASE}/nms/api/v2.1{path}"
    data = None
    if body is not None:
        data = json.dumps(body).encode()
    req = urllib.request.Request(url, data=data, method=method)
    req.add_header("X-Auth-Token", TOKEN)
    req.add_header("Accept", "application/json")
    if body is not None:
        req.add_header("Content-Type", "application/json")
    ctx = ssl.create_default_context()
    try:
        with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
            raw = resp.read()
    except urllib.error.HTTPError as e:
        raw = e.read()
        try:
            payload = json.loads(raw)
            msg = payload.get("message") or payload
        except Exception:
            msg = raw.decode("utf-8", "replace")[:200]
        die(f"{method} {url}: HTTP {e.code}: {msg}")
    if not raw:
        return None
    return json.loads(raw)


# ---------- Helpers ----------

def disp_name(d: dict) -> str:
    ident = d.get("identification") or {}
    return ident.get("hostname") or ident.get("name") or ident.get("displayName") or ident.get("mac") or "?"


def parse_version(v: str) -> tuple[int, ...]:
    """Loose semver parse: '6.3.24-cs' -> (6, 3, 24)."""
    if not v:
        return ()
    v = v.lstrip("v")
    head = ""
    for ch in v:
        if ch in "-+ ":
            break
        head += ch
    parts = []
    for chunk in head.split("."):
        n = 0
        for ch in chunk:
            if not ch.isdigit():
                break
            n = n * 10 + int(ch)
        parts.append(n)
    return tuple(parts)


def version_lt(a: str, b: str) -> bool:
    return parse_version(a) < parse_version(b)


def latest_firmware_for(fws: list[dict], d: dict) -> str:
    ident = d.get("identification") or {}
    plat = ident.get("platformId")
    model = ident.get("model")
    if not plat or not model:
        return ""
    best = ""
    for fw in fws:
        ident_fw = fw.get("identification") or {}
        if ident_fw.get("platformId") != plat:
            continue
        models = ident_fw.get("models") or []
        if models and model not in models:
            continue
        if not ident_fw.get("stable", True):
            continue
        v = ident_fw.get("version") or ""
        if not best or version_lt(best, v):
            best = v
    return best


def upgrade_in_flight(d: dict, target_v: str = "") -> bool:
    """True if UISP already has a pending/running upgrade for this device.
    Re-POSTing /tasks for a device with one queued returns a misleading
    422 "Firmware not found" error; skip these instead.
    """
    up = d.get("upgrade") or {}
    status = (up.get("status") or "").lower()
    if status not in {"queued", "running", "in-progress", "started", "preparing", "downloading", "installing"}:
        return False
    if not target_v:
        return True
    # Only skip if the queued upgrade is for the same target we'd request.
    return up.get("firmwareVersion") == target_v


def needs_upgrade(d: dict, fws: list[dict]) -> bool:
    ident = d.get("identification") or {}
    if ident.get("type") == "blackBox":
        return False
    model = ident.get("model")
    if not model or model == "UNKNOWN":
        return False
    cur = ident.get("firmwareVersion") or (d.get("firmware") or {}).get("current") or ""
    if not cur:
        return False
    latest = latest_firmware_for(fws, d)
    if not latest:
        return False
    if not version_lt(cur, latest):
        return False
    if upgrade_in_flight(d, latest):
        return False
    return True


def is_unauthorized(d: dict) -> bool:
    ident = d.get("identification") or {}
    overview = d.get("overview") or {}
    return not ident.get("authorized", True) or overview.get("status") == "discovered"


# ---------- Commands ----------

def cmd_list(args: argparse.Namespace) -> None:
    if args.unauthorized:
        devs = api("GET", "/devices/discovered") or []
        # SNMP-discovered stubs come back as model=UNKNOWN and aren't actually
        # adoptable. Hide by default to keep the pending-adoption view useful.
        if not args.include_unknown:
            devs = [d for d in devs if (d.get("identification") or {}).get("model") not in ("", None, "UNKNOWN")]
    else:
        devs = api("GET", "/devices?withInterfaces=false")

    if args.raw:
        json.dump(devs, sys.stdout, indent=2)
        print()
        return

    fws = api("GET", "/firmwares") or []

    rows = []
    for d in devs:
        if args.upgradable and not needs_upgrade(d, fws):
            continue
        rows.append(d)
    rows.sort(key=disp_name)

    if args.json:
        json.dump(rows, sys.stdout, indent=2)
        print()
        return

    headers = ["ID", "NAME", "MODEL", "SITE", "FW", "STATUS", "AUTH", "UPGRADE"]
    table = []
    for d in rows:
        ident = d.get("identification") or {}
        ov = d.get("overview") or {}
        cur = ident.get("firmwareVersion") or (d.get("firmware") or {}).get("current") or ""
        latest = latest_firmware_for(fws, d)
        fw = cur or "-"
        if latest and latest != cur and version_lt(cur, latest):
            fw = f"{cur} → {latest}"
        site_name = ""
        site = ident.get("site")
        if isinstance(site, dict):
            site_name = site.get("name") or ""
        table.append([
            ident.get("id", ""),
            disp_name(d),
            ident.get("modelName") or ident.get("model") or "",
            site_name,
            fw,
            ov.get("status") or "",
            "yes" if ident.get("authorized") else "NO",
            "yes" if needs_upgrade(d, fws) else "",
        ])
    widths = [max(len(h), *(len(r[i]) for r in table)) if table else len(h) for i, h in enumerate(headers)]
    print("  ".join(h.ljust(w) for h, w in zip(headers, widths)))
    for r in table:
        print("  ".join(c.ljust(w) for c, w in zip(r, widths)))


def lookup_site_id(name: str) -> str:
    sites = api("GET", "/sites")
    want = name.strip().lower()
    for s in sites:
        ident = s.get("identification") or {}
        n = ident.get("name") or s.get("name") or ""
        if n.lower() == want:
            return ident.get("id") or s.get("id")
    die(f"site {name!r} not found")


def wait_settle(ids: list[str], timeout_sec: int = 60) -> dict[str, dict]:
    """Poll /devices/discovered until none of the given IDs report
    isProcessing=true (or timeout). Returns the latest discovery dict per ID."""
    want = set(ids)
    deadline = time.time() + timeout_sec
    last: dict[str, dict] = {}
    while True:
        discovered = api("GET", "/devices/discovered") or []
        last = {}
        still = 0
        for d in discovered:
            ident = d.get("identification") or {}
            did = ident.get("id")
            if did not in want:
                continue
            disc = d.get("discovery") or {}
            last[did] = {**disc, "_device": d}
            if disc.get("isProcessing"):
                still += 1
        if still == 0 or time.time() > deadline:
            print(f"  settled (still processing: {still})")
            return last
        print(f"  ...waiting on {still} device(s)")
        time.sleep(5)


def cmd_approve(args: argparse.Namespace) -> None:
    discovered = api("GET", "/devices/discovered") or []
    by_id = {d["identification"]["id"]: d for d in discovered}

    if args.all:
        queue = list(discovered)
    else:
        if not args.ids:
            die("usage: uisp approve <id>... | --all")
        queue = []
        for did in args.ids:
            if did not in by_id:
                print(f"skip {did}: not in discovered list", file=sys.stderr)
                continue
            queue.append(by_id[did])

    # Devices with model "UNKNOWN" are SNMP-discovered stubs UISP can't manage —
    # connect/ubnt always fails on them, so don't burn cycles trying.
    if not args.include_unknown:
        before = len(queue)
        queue = [d for d in queue if (d.get("identification") or {}).get("model") not in ("", None, "UNKNOWN")]
        skipped = before - len(queue)
        if skipped:
            print(f"skipping {skipped} device(s) with model=UNKNOWN (use --include-unknown to override)")

    if not queue:
        print("no devices pending adoption.")
        return

    queue_ids = [d["identification"]["id"] for d in queue]
    print(f"queue ({len(queue)} device(s)):")
    for d in queue:
        ident = d["identification"]
        print(f"  {ident['id']}  {disp_name(d):<32}  {ident.get('modelName') or ident.get('model','')}")

    site_id = ""
    if args.site:
        site_id = lookup_site_id(args.site)
        print(f"site {args.site!r} resolved to id {site_id}")

    if args.dry_run:
        print(f"\ndry-run: would try {len(STANDARD_CREDS)} credential combo(s)" +
              (f" then assign to site {args.site!r}" if site_id else ""))
        return

    # Cred sweep — each pass only triggers connect on still-retryable IDs.
    retry_ids = list(queue_ids)
    for i, (user, pw) in enumerate(STANDARD_CREDS, 1):
        if not retry_ids:
            break
        print(f"\n[{i}/{len(STANDARD_CREDS)}] trying user={user} pw={pw} on {len(retry_ids)} device(s)...")
        try:
            api("POST", "/discovery/connect/ubnt", {
                "deviceIds": retry_ids,
                "username": user,
                "password": pw,
                "httpsPort": args.port,
            })
        except SystemExit:
            print("  connect call failed; moving on", file=sys.stderr)
            continue
        statuses = wait_settle(retry_ids, timeout_sec=args.poll_timeout)
        # Keep only IDs that are still retryable for the next cred.
        next_retry = []
        for did in retry_ids:
            st = statuses.get(did, {}).get("status", "")
            if st in RETRYABLE_DISCOVERY_STATUSES:
                next_retry.append(did)
        retry_ids = next_retry

    # Final reconciliation.
    fleet = api("GET", "/devices?withInterfaces=false") or []
    fleet_by_id = {d["identification"]["id"]: d for d in fleet}
    final_disc = {d["identification"]["id"]: d for d in (api("GET", "/devices/discovered") or [])}

    adopted, failed = [], []
    for did in queue_ids:
        d = fleet_by_id.get(did)
        if d and d["identification"].get("type") != "blackBox":
            adopted.append(did)
        else:
            failed.append(did)

    if adopted:
        print(f"\nadopted {len(adopted)} device(s):")
        for did in adopted:
            print(f"  {did}  {disp_name(fleet_by_id[did])}")

    if failed:
        # Group by status for a tidy summary.
        groups: dict[str, list[tuple[str, str, str]]] = {}
        for did in failed:
            d = final_disc.get(did) or by_id.get(did) or {}
            disc = d.get("discovery") or {}
            status = disc.get("status") or "unknown"
            err = disc.get("error") or ""
            groups.setdefault(status, []).append((did, disp_name(d), err))
        print(f"\n{len(failed)} device(s) still pending:")
        for status in sorted(groups):
            entries = groups[status]
            print(f"  [{status}]  ({len(entries)})")
            for did, name, err in entries:
                line = f"    {did}  {name}"
                if err:
                    line += f"  — {err}"
                print(line)

    if site_id and adopted:
        print(f"\nassigning {len(adopted)} adopted device(s) to site {args.site!r}...")
        api("POST", "/devices/authorize", {"siteId": site_id, "deviceIds": adopted})
        print("  done.")


def parse_uisp_time(s: str | None) -> dt.datetime | None:
    """UISP returns ISO 8601 like '2026-04-15T00:08:42.587Z'. Returns aware UTC dt."""
    if not s:
        return None
    s = s.replace("Z", "+00:00")
    try:
        return dt.datetime.fromisoformat(s)
    except ValueError:
        return None


def cmd_prune(args: argparse.Namespace) -> None:
    devs = api("GET", "/devices?withInterfaces=false") or []
    now = dt.datetime.now(dt.timezone.utc)
    cutoff = now - dt.timedelta(days=args.days)

    stale = []
    for d in devs:
        ident = d.get("identification") or {}
        ov = d.get("overview") or {}
        last_seen = parse_uisp_time(ov.get("lastSeen"))
        if last_seen is not None and last_seen >= cutoff:
            continue
        if last_seen is None and args.skip_never_seen:
            continue
        # Skip backbone routers / switches by default — losing one of these from
        # UISP just because it's not reporting in would be an own-goal.
        if not args.include_infra and ident.get("type") in {"erouter", "eswitch", "olt"}:
            continue
        if not args.include_infra and ident.get("role") in {"router", "switch", "gateway"}:
            continue
        stale.append((d, last_seen))

    if not stale:
        print(f"no devices offline longer than {args.days} day(s).")
        return

    stale.sort(key=lambda t: t[1] or dt.datetime.min.replace(tzinfo=dt.timezone.utc))

    # Summary buckets so the user can sanity-check before approving.
    summary: Counter = Counter()
    type_summary: Counter = Counter()
    for d, last_seen in stale:
        if last_seen is None:
            bucket = "never seen"
        else:
            age_days = (now - last_seen).days
            if age_days > 30:
                bucket = ">30d offline"
            elif age_days > 7:
                bucket = "7-30d offline"
            else:
                bucket = "1-7d offline"
        summary[bucket] += 1
        type_summary[(bucket, (d.get("identification") or {}).get("type") or "?")] += 1

    print(f"{len(stale)} device(s) eligible for prune (offline > {args.days}d, including never-seen={'no' if args.skip_never_seen else 'yes'}):")
    for bucket, n in summary.most_common():
        print(f"  {n:>4}  {bucket}")
    print()
    print("by bucket × type:")
    for (bucket, t), n in sorted(type_summary.items()):
        print(f"  {n:>4}  {bucket:<14}  type={t}")
    print()

    if args.verbose:
        print("detail:")
        for d, last_seen in stale:
            ident = d["identification"]
            site_name = ""
            if isinstance(ident.get("site"), dict):
                site_name = ident["site"].get("name") or ""
            age = "never seen" if last_seen is None else f"{(now - last_seen).days}d"
            print(f"  {ident['id']}  {disp_name(d):<30}  "
                  f"{ident.get('modelName') or ident.get('model',''):<22}  "
                  f"site={site_name:<14}  offline={age}")
        print()

    if not args.yes:
        print(f"dry-run: pass --yes to delete these {len(stale)} device(s) (use --verbose to see each one).")
        return

    ids = [d["identification"]["id"] for d, _ in stale]
    api("POST", "/devices/bulkdelete", {"ids": ids})
    print(f"deleted {len(ids)} device(s).")


# Whitelist of fields UISP's PUT /nms/settings will accept. Extracted from the
# UI bundle (`hZr` array). Anything else triggers a 400 like "X is not allowed".
SETTINGS_PUT_ALLOWED = {
    "allowAutoUpdateUbntFirmwares", "allowBetaFirmwares", "allowLoggingToLogentries",
    "allowLoggingToSentry", "allowNewFirmware", "autoUpdatePlatforms", "country",
    "dateFormat", "defaultGracePeriod", "defaultQosPropagation", "devicePingAddress",
    "devicePingAddressMode", "devicePingIntervalNormal", "devicePingIntervalOutage",
    "deviceTransmissionFrequencies", "deviceTransmissionProfile", "deviceUpdateNotification",
    "discoveryAllowLocalScan", "discoveryAllowRemoteScan", "discoveryAllowUnsecuredChannels",
    "discoveryAutoConfiguration", "discoveryBlacklist", "discoveryHideBlackBox",
    "discoveryNotification", "discoverySnmpCommunity", "googleMapsApiKey", "homePage",
    "hostname", "isOnuDisabledOnSubscriberSuspend", "maintenanceWindowFriday",
    "maintenanceWindowFromTime", "maintenanceWindowMonday", "maintenanceWindowSaturday",
    "maintenanceWindowSunday", "maintenanceWindowThursday", "maintenanceWindowToTime",
    "maintenanceWindowTuesday", "maintenanceWindowWednesday", "mapsProvider",
    "migrationForceModeEnabled", "migrationWithBackupEnabled", "migrationUispKey",
    "migrationHostname", "migrationModeEnabled", "migrationPort", "outageMailablePeriod",
    "parallelUpgradeLimit", "restartGracePeriod", "tableDensity", "timeFormat",
    "timezone", "trafficShapingAdjustment", "upgradeGracePeriod", "useLetsEncrypt",
}


def put_nms_settings(updates: dict) -> None:
    """GET current settings, merge updates, filter to PUT-allowed fields, PUT."""
    current = api("GET", "/nms/settings") or {}
    merged = {**current, **updates}
    body = {k: v for k, v in merged.items() if k in SETTINGS_PUT_ALLOWED}
    api("PUT", "/nms/settings", body)


def cmd_ignore(args: argparse.Namespace) -> None:
    """Add IPs of model=UNKNOWN devices to UISP's discoveryBlacklist so they
    stop reappearing, then delete the stub records."""
    # Pull from both /devices/discovered (pending) and /devices (adopted but
    # blackBox) since the SNMP-stub population shows up in both lists.
    discovered = api("GET", "/devices/discovered") or []
    fleet = api("GET", "/devices?withInterfaces=false") or []
    pool = []
    for d in discovered + fleet:
        ident = d.get("identification") or {}
        if ident.get("model") in ("", None, "UNKNOWN") or ident.get("type") == "blackBox":
            pool.append(d)
    # Dedupe by id (a device can appear in both lists).
    seen_ids: set[str] = set()
    targets = []
    for d in pool:
        did = (d.get("identification") or {}).get("id")
        if did and did not in seen_ids:
            seen_ids.add(did)
            targets.append(d)

    ip_set: dict[str, dict] = {}  # ip -> first device that has it
    no_ip = []
    for d in targets:
        ip = d.get("ipAddress") or ""
        ip = ip.split("/")[0].strip()
        if ip:
            ip_set.setdefault(ip, d)
        else:
            no_ip.append(d)

    if not ip_set and not no_ip:
        print("no UNKNOWN/blackBox devices found.")
        return

    settings = api("GET", "/nms/settings")
    current_bl = settings.get("discoveryBlacklist") or []
    if isinstance(current_bl, str):
        current_bl = [x.strip() for x in current_bl.split(",") if x.strip()]
    current_set = set(current_bl)
    new_ips = sorted(ip for ip in ip_set if ip not in current_set)

    print(f"found {len(targets)} UNKNOWN/blackBox device(s) "
          f"({len(ip_set)} unique IP(s), {len(no_ip)} without IP)")
    print(f"already blacklisted: {len(current_set)}")
    print(f"new IPs to blacklist: {len(new_ips)}")
    if new_ips and args.verbose:
        for ip in new_ips:
            d = ip_set[ip]
            ident = d["identification"]
            print(f"  {ip:<16}  {ident.get('hostname') or ident.get('name') or '?'}")

    if args.dry_run:
        print(f"\ndry-run: would add {len(new_ips)} IP(s) to discoveryBlacklist "
              f"and delete {len(targets)} stub record(s).")
        return

    if new_ips:
        merged = sorted(current_set | set(new_ips))
        put_nms_settings({"discoveryBlacklist": merged})
        print(f"blacklist updated: {len(current_set)} → {len(merged)} entries")

    if targets:
        ids = [d["identification"]["id"] for d in targets]
        # bulkdelete handles batches comfortably; UISP returns 200 with no body.
        api("POST", "/devices/bulkdelete", {"ids": ids})
        print(f"deleted {len(ids)} stub record(s).")


def cmd_upgrade(args: argparse.Namespace) -> None:
    devs = api("GET", "/devices?withInterfaces=false") or []
    fws = api("GET", "/firmwares") or []
    by_id = {d["identification"]["id"]: d for d in devs}

    targets = []
    if args.all:
        for d in devs:
            if needs_upgrade(d, fws):
                targets.append(d)
    else:
        if not args.ids:
            die("usage: uisp upgrade <id>... | --all")
        for did in args.ids:
            d = by_id.get(did)
            if not d:
                print(f"skip {did}: not found", file=sys.stderr)
                continue
            if not needs_upgrade(d, fws) and not args.force:
                ident = d["identification"]
                cur = ident.get("firmwareVersion") or "?"
                latest = latest_firmware_for(fws, d) or "?"
                print(f"skip {did} ({disp_name(d)}): already on {cur} (latest {latest}) — pass --force to override",
                      file=sys.stderr)
                continue
            targets.append(d)

    if not targets:
        print("no devices need upgrade.")
        return

    print(f"upgrade plan ({len(targets)} device(s)):")
    for d in targets:
        ident = d["identification"]
        cur = ident.get("firmwareVersion") or "?"
        latest = latest_firmware_for(fws, d) or "?"
        print(f"  {ident['id']}  {disp_name(d):<30}  {cur} → {latest}")

    if not args.yes:
        print(f"\ndry-run: pass --yes to actually trigger upgrades.")
        return

    batch_size = args.batch_size if args.batch_size > 0 else len(targets)
    n_batches = (len(targets) + batch_size - 1) // batch_size
    failed_total = 0

    for batch_idx in range(n_batches):
        batch = targets[batch_idx * batch_size:(batch_idx + 1) * batch_size]
        print(f"\n=== batch {batch_idx + 1}/{n_batches}: {len(batch)} device(s) ===")

        # POST /tasks one device at a time. Bulk submission (all device
        # upgrades in a single /tasks payload) is atomic: any one device
        # whose target firmwareVersion doesn't exactly match a catalog
        # entry causes the whole call to 422 with "Firmware not found".
        # Per-device /tasks isolates failures.
        triggered: list[tuple[str, str, str]] = []  # (id, name, target_version)
        for d in batch:
            did = d["identification"]["id"]
            target_v = latest_firmware_for(fws, d)
            if not target_v:
                print(f"  skip   {did}  {disp_name(d):<30}: no catalog firmware match", file=sys.stderr)
                failed_total += 1
                continue
            if upgrade_in_flight(d, target_v):
                up = d.get("upgrade") or {}
                print(f"  skip   {did}  {disp_name(d):<30}: already {up.get('status')} → {up.get('firmwareVersion')}")
                continue
            try:
                api("POST", "/tasks", {
                    "upgrades": [{"deviceId": did, "firmwareVersion": target_v}],
                    "upgradeInMaintenanceWindow": False,
                })
                print(f"  trigger OK   {did}  {disp_name(d):<30}  → {target_v}")
                triggered.append((did, disp_name(d), target_v))
            except SystemExit:
                print(f"  trigger FAIL {did}  {disp_name(d):<30}  → {target_v}", file=sys.stderr)
                failed_total += 1

        if not args.wait:
            continue

        # Wait for every device in this batch to land on its target version.
        deadline = time.time() + args.wait_timeout
        pending = {did: (name, tv) for did, name, tv in triggered}
        while pending and time.time() < deadline:
            time.sleep(20)
            current = api("GET", "/devices?withInterfaces=false") or []
            cur_by_id = {x["identification"]["id"]: x for x in current}
            for did in list(pending.keys()):
                name, tv = pending[did]
                d = cur_by_id.get(did)
                if not d:
                    continue
                cur = d["identification"].get("firmwareVersion") or ""
                if cur and tv != "?" and not version_lt(cur, tv):
                    print(f"  done       {did}  {name:<30}  on {cur}")
                    del pending[did]
            if pending:
                print(f"  ...waiting on {len(pending)} device(s) "
                      f"({int(deadline - time.time())}s left)")

        if pending:
            print(f"  batch timeout: {len(pending)} device(s) didn't finish in {args.wait_timeout}s; "
                  f"continuing anyway")
            for did, (name, tv) in pending.items():
                print(f"    still pending  {did}  {name}  → {tv}")
                failed_total += 1

    if failed_total:
        print(f"\n{failed_total} device(s) had problems (failed trigger or didn't complete in time).")
    else:
        print(f"\nall {len(targets)} device(s) processed.")


# ---------- argparse wiring ----------

def main() -> None:
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", required=True)

    pl = sub.add_parser("list", help="list devices")
    pl.add_argument("--unauthorized", action="store_true", help="show only pending-adoption devices")
    pl.add_argument("--upgradable", action="store_true", help="show only devices with a firmware update")
    pl.add_argument("--json", action="store_true", help="emit parsed JSON")
    pl.add_argument("--raw", action="store_true", help="dump raw API JSON (debug)")
    pl.add_argument("--include-unknown", action="store_true",
                    help="include model=UNKNOWN devices in --unauthorized output (hidden by default)")
    pl.set_defaults(func=cmd_list)

    pa = sub.add_parser("approve", help="adopt pending-adoption devices")
    pa.add_argument("ids", nargs="*", help="device IDs to adopt")
    pa.add_argument("--all", action="store_true", help="adopt every pending-adoption device")
    pa.add_argument("--site", default="vntx", help="site name to assign adopted devices to (empty = skip)")
    pa.add_argument("--port", type=int, default=443, help="HTTPS port UISP uses to reach the device")
    pa.add_argument("--poll-timeout", type=int, default=60, help="seconds to wait for each cred attempt to settle")
    pa.add_argument("--dry-run", action="store_true")
    pa.add_argument("--include-unknown", action="store_true",
                    help="also try to adopt devices with model=UNKNOWN (SNMP stubs; almost never adoptable)")
    pa.set_defaults(func=cmd_approve)

    pi = sub.add_parser("ignore",
                        help="blacklist IPs of UNKNOWN/blackBox devices and delete the stubs so they stop coming back")
    pi.add_argument("--dry-run", action="store_true")
    pi.add_argument("--verbose", "-v", action="store_true", help="list every IP being added")
    pi.set_defaults(func=cmd_ignore)

    pp = sub.add_parser("prune", help="delete devices offline longer than N days")
    pp.add_argument("--days", type=float, default=1.0, help="offline threshold in days (default: 1)")
    pp.add_argument("--yes", action="store_true", help="actually delete (default: dry-run)")
    pp.add_argument("--include-infra", action="store_true",
                    help="also prune routers/switches/OLTs (default: skipped)")
    pp.add_argument("--skip-never-seen", action="store_true",
                    help="exclude devices with no lastSeen timestamp (SNMP stubs that never adopted)")
    pp.add_argument("--verbose", "-v", action="store_true", help="list every device, not just summary")
    pp.set_defaults(func=cmd_prune)

    pu = sub.add_parser("upgrade", help="upgrade firmware")
    pu.add_argument("ids", nargs="*", help="device IDs to upgrade")
    pu.add_argument("--all", action="store_true", help="upgrade every device with a newer firmware available")
    pu.add_argument("--yes", action="store_true", help="actually trigger upgrades (default: dry-run)")
    pu.add_argument("--force", action="store_true", help="upgrade even if already on latest")
    pu.add_argument("--batch-size", type=int, default=0,
                    help="devices per batch; 0 means all in one batch (default). Only useful with --wait.")
    pu.add_argument("--wait", action="store_true",
                    help="wait for each batch to land on the new firmware before starting the next")
    pu.add_argument("--wait-timeout", type=int, default=900,
                    help="seconds to wait per batch when --wait is set (default: 900)")
    pu.set_defaults(func=cmd_upgrade)

    args = p.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()
