// inventory subcommand: walks every router in routers.yaml, pulls
// /ip/address, /ip/arp, /ip/pool, and /ip/neighbor via the API, SNMP-probes
// each candidate radio for sysName, and re-renders inventory.yaml in place.
//
// Customer-facing equipment (anything in a customer CPE/CGNAT pool, plus
// LLDP/CDP entries that advertise wlan-ap or station-only capability) is
// deliberately excluded. The output covers only tower-side infrastructure:
// access points, backhaul radios, and tower switches.
//
// The existing inventory.yaml (or radios.yaml as a one-time fallback) is
// read as a seed: site names, parent_router links, and per-/29 backhaul
// link labels survive the refresh.

package main

import (
	"bytes"
	"fmt"
	"log"
	"net/netip"
	"os"
	"sort"
	"strings"
	"sync"
	"time"

	"github.com/gosnmp/gosnmp"
	"gopkg.in/yaml.v3"
)

const (
	snmpCommunity    = "kdyyJrT0Mm"
	snmpOIDsysName   = "1.3.6.1.2.1.1.5.0"
	snmpWorkers      = 16
	snmpTimeout      = 3 * time.Second
	snmpRetries      = 1
	backhaulNetwork  = "10.250.1.0/24"
	cgnatNetwork     = "100.64.0.0/10" // RFC 6598
	rfc1918Net10     = "10.0.0.0/8"
	inventoryPath    = "inventory.yaml"
	legacyRadiosPath = "radios.yaml" // one-time fallback seed
)

// ---------------------------------------------------------------------------
// seed yaml: what we keep from the existing inventory.yaml

type seedSite struct {
	Name         string
	Router       string       // primary site has this
	ParentRouter string       // satellite has this instead
	MgmtSubnet   netip.Prefix // top /24 holds APs
	// /29 → link label; we re-attach this when emitting backhaul radios.
	LinkLabels map[netip.Prefix]string
}

type seed struct {
	Order  []string             // site key insertion order
	Sites  map[string]*seedSite // by site key
	Header []byte               // leading comment block, copied verbatim
}

// loadSeedInventory parses inventory.yaml (falling back to a legacy
// radios.yaml if inventory.yaml is missing). If neither exists we return
// an empty seed so the user can bootstrap from nothing.
func loadSeedInventory(path string) (*seed, error) {
	data, err := os.ReadFile(path)
	if err != nil && os.IsNotExist(err) && path == inventoryPath {
		// One-time migration: read the old file but write to the new one.
		legacy, lerr := os.ReadFile(legacyRadiosPath)
		if lerr == nil {
			log.Printf("[inventory] seeding from legacy %s — output will be %s",
				legacyRadiosPath, inventoryPath)
			data, err = legacy, nil
		}
	}
	if err != nil {
		if os.IsNotExist(err) {
			return &seed{Sites: map[string]*seedSite{}}, nil
		}
		return nil, fmt.Errorf("read %s: %w", path, err)
	}

	s := &seed{Sites: map[string]*seedSite{}}
	s.Header = leadingComments(data)

	// Parse the document twice: once as raw nodes (to get site order), once
	// as a typed struct (to extract fields cleanly).
	var doc struct {
		Sites yaml.Node `yaml:"sites"`
	}
	if err := yaml.Unmarshal(data, &doc); err != nil {
		return nil, fmt.Errorf("parse %s: %w", path, err)
	}
	if doc.Sites.Kind != yaml.MappingNode {
		return s, nil
	}

	for i := 0; i+1 < len(doc.Sites.Content); i += 2 {
		keyNode := doc.Sites.Content[i]
		valNode := doc.Sites.Content[i+1]

		var raw struct {
			Router         string `yaml:"router"`
			ParentRouter   string `yaml:"parent_router"`
			MgmtSubnet     string `yaml:"mgmt_subnet"`
			BackhaulRadios []struct {
				IP   string `yaml:"ip"`
				Link string `yaml:"link"`
			} `yaml:"backhaul_radios"`
		}
		if err := valNode.Decode(&raw); err != nil {
			return nil, fmt.Errorf("decode site %q: %w", keyNode.Value, err)
		}

		site := &seedSite{
			Name:         keyNode.Value,
			Router:       raw.Router,
			ParentRouter: raw.ParentRouter,
			LinkLabels:   map[netip.Prefix]string{},
		}
		if raw.MgmtSubnet != "" {
			p, err := netip.ParsePrefix(raw.MgmtSubnet)
			if err != nil {
				return nil, fmt.Errorf("site %q mgmt_subnet %q: %w", site.Name, raw.MgmtSubnet, err)
			}
			site.MgmtSubnet = p.Masked()
		}
		for _, br := range raw.BackhaulRadios {
			ip, err := netip.ParseAddr(br.IP)
			if err != nil || br.Link == "" {
				continue
			}
			pfx := netip.PrefixFrom(ip, 29).Masked()
			// Only the first occurrence wins; a /29 should have one label.
			if _, ok := site.LinkLabels[pfx]; !ok {
				site.LinkLabels[pfx] = br.Link
			}
		}

		s.Order = append(s.Order, site.Name)
		s.Sites[site.Name] = site
	}
	return s, nil
}

// leadingComments returns the byte prefix of `data` containing the initial
// run of comment / blank lines, ending with a trailing newline. We splice
// this back onto the regenerated yaml so the documentation header survives.
func leadingComments(data []byte) []byte {
	var out bytes.Buffer
	for _, line := range strings.SplitAfter(string(data), "\n") {
		trimmed := strings.TrimLeft(line, " \t")
		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
			out.WriteString(line)
			continue
		}
		break
	}
	return out.Bytes()
}

// ---------------------------------------------------------------------------
// per-router discovery: pull /ip/address and /ip/arp via the API

type addrEntry struct {
	Addr      netip.Addr   // the router's local IP on this subnet
	Prefix    netip.Prefix // the network in CIDR form
	Interface string
}

type arpEntry struct {
	Addr netip.Addr
	MAC  string
}

// neighborEntry is one CDP/LLDP/MNDP discovered device.
type neighborEntry struct {
	Addr        netip.Addr
	MAC         string
	Identity    string
	Platform    string
	Caps        string // raw "system-caps" string, comma-separated
	Interface   string
}

// poolRange is a contiguous range of addresses from /ip/pool, used to
// recognise customer-CPE / customer-CGNAT subnets we want to skip.
type poolRange struct {
	Name string
	Lo   netip.Addr
	Hi   netip.Addr
}

func (p poolRange) contains(a netip.Addr) bool {
	if !a.IsValid() || !p.Lo.IsValid() || !p.Hi.IsValid() {
		return false
	}
	return a.Compare(p.Lo) >= 0 && a.Compare(p.Hi) <= 0
}

type routerDiscovery struct {
	Router    Router // resolved
	OwnIP     netip.Addr
	Addresses []addrEntry
	ARP       []arpEntry
	Neighbors []neighborEntry
	Customer  []poolRange // customer pool ranges to exclude
}

// mgmtSubnets returns the router-owned /20s within 10.0.0.0/8.
func (d *routerDiscovery) mgmtSubnets() []netip.Prefix {
	rfc10 := netip.MustParsePrefix(rfc1918Net10)
	var out []netip.Prefix
	for _, a := range d.Addresses {
		if a.Prefix.Bits() == 20 && rfc10.Contains(a.Prefix.Addr()) {
			out = append(out, a.Prefix)
		}
	}
	return out
}

// cgnatSubnets returns router-owned subnets within RFC 6598 100.64.0.0/10,
// paired with the router's gateway IP.
func (d *routerDiscovery) cgnatSubnets() []cgnat {
	cgnatRange := netip.MustParsePrefix(cgnatNetwork)
	var out []cgnat
	for _, a := range d.Addresses {
		if cgnatRange.Contains(a.Prefix.Addr()) {
			out = append(out, cgnat{Subnet: a.Prefix, Gateway: a.Addr})
		}
	}
	return out
}

// backhaulPrefixes returns router-owned /29s in 10.250.1.0/24.
func (d *routerDiscovery) backhaulPrefixes() []netip.Prefix {
	bhRange := netip.MustParsePrefix(backhaulNetwork)
	var out []netip.Prefix
	for _, a := range d.Addresses {
		if a.Prefix.Bits() == 29 && bhRange.Contains(a.Prefix.Addr()) {
			out = append(out, a.Prefix)
		}
	}
	return out
}

type cgnat struct {
	Subnet  netip.Prefix
	Gateway netip.Addr
}

func discoverRouter(cfg *Config, r Router) (*routerDiscovery, error) {
	r = cfg.resolve(r)
	if r.Host == "" || r.Name == "" {
		return nil, fmt.Errorf("router needs both name and host")
	}
	host, err := netip.ParseAddr(r.Host)
	if err != nil {
		return nil, fmt.Errorf("router host %q: %w", r.Host, err)
	}

	c, err := dialAPI(r)
	if err != nil {
		return nil, err
	}
	defer c.Close()

	d := &routerDiscovery{Router: r, OwnIP: host}

	addrRep, err := c.RunArgs([]string{
		"/ip/address/print",
		"=.proplist=address,network,interface",
	})
	if err != nil {
		return nil, fmt.Errorf("/ip/address/print: %w", err)
	}
	for _, row := range addrRep.Re {
		raw := row.Map["address"] // "10.10.15.254/20"
		network := row.Map["network"]
		if raw == "" || network == "" {
			continue
		}
		slash := strings.IndexByte(raw, '/')
		if slash < 0 {
			continue
		}
		ip, err := netip.ParseAddr(raw[:slash])
		if err != nil {
			continue
		}
		var bits int
		if _, err := fmt.Sscanf(raw[slash+1:], "%d", &bits); err != nil {
			continue
		}
		netIP, err := netip.ParseAddr(network)
		if err != nil {
			continue
		}
		d.Addresses = append(d.Addresses, addrEntry{
			Addr:      ip,
			Prefix:    netip.PrefixFrom(netIP, bits),
			Interface: row.Map["interface"],
		})
	}

	arpRep, err := c.RunArgs([]string{
		"/ip/arp/print",
		"=.proplist=address,mac-address,complete",
	})
	if err != nil {
		return nil, fmt.Errorf("/ip/arp/print: %w", err)
	}
	for _, row := range arpRep.Re {
		if row.Map["complete"] != "true" {
			continue
		}
		mac := row.Map["mac-address"]
		if mac == "" {
			continue
		}
		ip, err := netip.ParseAddr(row.Map["address"])
		if err != nil {
			continue
		}
		d.ARP = append(d.ARP, arpEntry{Addr: ip, MAC: strings.ToUpper(mac)})
	}

	// /ip/pool — used to identify customer pool ranges so we can skip them.
	poolRep, err := c.RunArgs([]string{
		"/ip/pool/print",
		"=.proplist=name,ranges",
	})
	if err == nil {
		for _, row := range poolRep.Re {
			name := row.Map["name"]
			if !customerPoolName(name) {
				continue
			}
			for _, r := range strings.Split(row.Map["ranges"], ",") {
				r = strings.TrimSpace(r)
				if r == "" {
					continue
				}
				lo, hi := r, r
				if i := strings.IndexByte(r, '-'); i >= 0 {
					lo, hi = strings.TrimSpace(r[:i]), strings.TrimSpace(r[i+1:])
				}
				loA, e1 := netip.ParseAddr(lo)
				hiA, e2 := netip.ParseAddr(hi)
				if e1 != nil || e2 != nil {
					continue
				}
				d.Customer = append(d.Customer, poolRange{Name: name, Lo: loA, Hi: hiA})
			}
		}
	}

	// /ip/neighbor — for tower switch discovery via LLDP/CDP.
	nbrRep, err := c.RunArgs([]string{
		"/ip/neighbor/print",
		"=.proplist=address,mac-address,identity,platform,system-caps,interface",
	})
	if err == nil {
		for _, row := range nbrRep.Re {
			ip, _ := netip.ParseAddr(row.Map["address"])
			d.Neighbors = append(d.Neighbors, neighborEntry{
				Addr:      ip,
				MAC:       strings.ToUpper(row.Map["mac-address"]),
				Identity:  row.Map["identity"],
				Platform:  row.Map["platform"],
				Caps:      row.Map["system-caps"],
				Interface: row.Map["interface"],
			})
		}
	}
	return d, nil
}

// customerPoolName returns true for pools that hand out customer-side
// addresses we want to filter out of inventory discovery. Names observed
// across the existing routers: verona-cpe, altoga-cpe, culleoka-cpe,
// verona-cgnat, altoga-cgnat, climax cgnat, newhope cgnat-full, etc.
func customerPoolName(name string) bool {
	n := strings.ToLower(name)
	switch {
	case strings.Contains(n, "cpe"),
		strings.Contains(n, "cgnat"),
		strings.HasPrefix(n, "dhcp_pool"),
		strings.Contains(n, "customer"):
		return true
	}
	return false
}

// inCustomerPool reports whether the given IP falls in any of the discovery's
// customer pool ranges.
func (d *routerDiscovery) inCustomerPool(a netip.Addr) bool {
	for _, p := range d.Customer {
		if p.contains(a) {
			return true
		}
	}
	return false
}

// ---------------------------------------------------------------------------
// SNMP probing

// probeSysName fetches sysName.0 from the radio at ip, trying SNMP v2c first
// then v1. Returns "" on unrecoverable failure.
func probeSysName(ip string) (string, error) {
	for _, ver := range []gosnmp.SnmpVersion{gosnmp.Version2c, gosnmp.Version1} {
		g := &gosnmp.GoSNMP{
			Target:    ip,
			Port:      161,
			Community: snmpCommunity,
			Version:   ver,
			Timeout:   snmpTimeout,
			Retries:   snmpRetries,
			MaxOids:   1,
		}
		if err := g.Connect(); err != nil {
			continue
		}
		pkt, err := g.Get([]string{snmpOIDsysName})
		g.Conn.Close()
		if err != nil || len(pkt.Variables) == 0 {
			continue
		}
		v := pkt.Variables[0]
		switch val := v.Value.(type) {
		case []byte:
			return string(val), nil
		case string:
			return val, nil
		}
	}
	return "", fmt.Errorf("snmp sysName failed (v2c+v1)")
}

// probeAll runs probeSysName for every IP in `ips` with a worker pool.
// Returned map omits IPs whose probe failed; callers should treat absent
// entries as nil.
func probeAll(ips []netip.Addr) map[netip.Addr]string {
	type result struct {
		ip   netip.Addr
		name string
	}
	jobs := make(chan netip.Addr, len(ips))
	results := make(chan result, len(ips))

	var wg sync.WaitGroup
	workers := snmpWorkers
	if workers > len(ips) {
		workers = len(ips)
	}
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for ip := range jobs {
				name, err := probeSysName(ip.String())
				if err != nil {
					log.Printf("[snmp] %s: %v", ip, err)
					continue
				}
				results <- result{ip: ip, name: name}
			}
		}()
	}
	for _, ip := range ips {
		jobs <- ip
	}
	close(jobs)
	wg.Wait()
	close(results)

	out := make(map[netip.Addr]string, len(ips))
	for r := range results {
		out[r.ip] = r.name
	}
	return out
}

// ---------------------------------------------------------------------------
// site assembly

// outRadio is one rendered yaml entry. mac == "" → null in yaml; same for name.
type outRadio struct {
	IP   netip.Addr
	MAC  string
	Name string
	Link string // backhauls only
}

// outSwitch is one tower-side switch (Netonix, MikroTik switch chassis,
// etc.) discovered via LLDP/CDP.
type outSwitch struct {
	IP    netip.Addr
	MAC   string
	Name  string
	Model string
}

type outSite struct {
	Name           string
	Router         string
	ParentRouter   string
	MgmtSubnet     netip.Prefix
	Cgnats         []cgnat
	AccessPoints   []outRadio
	BackhaulRadios []outRadio
	Switches       []outSwitch
}

// hasCap reports whether a comma-separated system-caps string contains the
// given capability token.
func hasCap(caps, want string) bool {
	for _, c := range strings.Split(caps, ",") {
		if strings.EqualFold(strings.TrimSpace(c), want) {
			return true
		}
	}
	return false
}

// collectSwitches returns LLDP/CDP neighbors of d that look like tower
// switches: bridge-capable, not a wireless AP/station, in our own address
// space, on a directly-connected subnet of d, and not one of our own
// routers. We do NOT apply the customer-pool exclusion here because some
// infrastructure (e.g. Verona/Culleoka Netonix) is intentionally pinned to
// a static address that lives inside a CGNAT pool range.
func collectSwitches(d *routerDiscovery, routerIPs map[netip.Addr]bool) []outSwitch {
	rfc10 := netip.MustParsePrefix(rfc1918Net10)
	cgnatRange := netip.MustParsePrefix(cgnatNetwork)
	seen := map[netip.Addr]bool{}
	var out []outSwitch
	for _, n := range d.Neighbors {
		if !n.Addr.IsValid() || !n.Addr.Is4() {
			continue
		}
		if seen[n.Addr] {
			continue
		}
		if routerIPs[n.Addr] {
			continue
		}
		if !hasCap(n.Caps, "bridge") {
			continue
		}
		if hasCap(n.Caps, "wlan-ap") || hasCap(n.Caps, "station-only") {
			continue
		}
		// Address must be in our own network space (skips 192.168/16 etc.).
		if !rfc10.Contains(n.Addr) && !cgnatRange.Contains(n.Addr) {
			continue
		}
		// Address must live in a subnet this router is directly attached
		// to — filters out neighbors leaked through tunnels / mesh mNDP.
		direct := false
		for _, a := range d.Addresses {
			if a.Prefix.Contains(n.Addr) {
				direct = true
				break
			}
		}
		if !direct {
			continue
		}
		seen[n.Addr] = true
		out = append(out, outSwitch{
			IP:    n.Addr,
			MAC:   n.MAC,
			Name:  n.Identity,
			Model: n.Platform,
		})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].IP.Less(out[j].IP) })
	return out
}

// topSlash24 returns the top /24 within a /20 (the AP subnet).
// CLAUDE.md formula: for X.Y.Z.0/20, APs are in X.Y.(Z+15).0/24.
func topSlash24(p20 netip.Prefix) netip.Prefix {
	a := p20.Addr().As4()
	a[2] += 15
	a[3] = 0
	return netip.PrefixFrom(netip.AddrFrom4(a), 24)
}

// assembleSites takes seed metadata and per-router discovery + SNMP results,
// and produces the ordered output site list ready for yaml rendering.
func assembleSites(seed *seed, discoveries []*routerDiscovery, names map[netip.Addr]string) []*outSite {
	// Index discoveries by router host IP for cross-references.
	byHost := map[netip.Addr]*routerDiscovery{}
	for _, d := range discoveries {
		byHost[d.OwnIP] = d
	}

	// Set of every IP any router owns. Used to filter out the far-end
	// router from /29 ARP candidates so we don't mistake it for a radio.
	routerIPs := map[netip.Addr]bool{}
	for _, d := range discoveries {
		routerIPs[d.OwnIP] = true
		for _, a := range d.Addresses {
			routerIPs[a.Addr] = true
		}
	}

	// Map mgmt /20 → seed site name; also remember the parent-router-aware
	// "owning router host" for that site so we know where to look for ARP.
	siteByMgmt := map[netip.Prefix]string{}
	siteHost := map[string]netip.Addr{} // site name → owning router IP (for ARP source)
	for _, name := range seed.Order {
		s := seed.Sites[name]
		if !s.MgmtSubnet.IsValid() {
			continue
		}
		siteByMgmt[s.MgmtSubnet] = name
		hostStr := s.Router
		if hostStr == "" {
			hostStr = s.ParentRouter
		}
		if h, err := netip.ParseAddr(hostStr); err == nil {
			siteHost[name] = h
		}
	}

	// /29 → set of owning routers (the routers that have a local IP in it).
	// Used for closest-router-IP backhaul ownership and to gate which /29s
	// count as a real link (must have ≥2 routers).
	type pfxOwners struct {
		prefix  netip.Prefix
		routers []*routerDiscovery
	}
	bh29 := map[netip.Prefix]*pfxOwners{}
	for _, d := range discoveries {
		for _, p := range d.backhaulPrefixes() {
			pm := p.Masked()
			po, ok := bh29[pm]
			if !ok {
				po = &pfxOwners{prefix: pm}
				bh29[pm] = po
			}
			po.routers = append(po.routers, d)
		}
	}

	// Build out-sites in seed order.
	out := make([]*outSite, 0, len(seed.Order))
	seenSites := map[string]bool{}

	addOut := func(o *outSite) {
		out = append(out, o)
		seenSites[o.Name] = true
	}

	for _, siteName := range seed.Order {
		s := seed.Sites[siteName]
		o := &outSite{
			Name:         siteName,
			Router:       s.Router,
			ParentRouter: s.ParentRouter,
			MgmtSubnet:   s.MgmtSubnet,
		}

		// CGNAT only attaches to primary sites (parent_router satellites
		// don't list cgnat in the existing radios.yaml).
		if s.Router != "" {
			if h, err := netip.ParseAddr(s.Router); err == nil {
				if d := byHost[h]; d != nil {
					o.Cgnats = d.cgnatSubnets()
				}
			}
		}

		// Access points: ARP entries on the owning router, in this site's
		// top /24, minus customer pool ranges.
		if owner := siteHost[siteName]; owner.IsValid() && s.MgmtSubnet.IsValid() {
			d := byHost[owner]
			if d != nil {
				ap24 := topSlash24(s.MgmtSubnet)
				for _, e := range d.ARP {
					if !ap24.Contains(e.Addr) {
						continue
					}
					if routerIPs[e.Addr] {
						continue
					}
					if d.inCustomerPool(e.Addr) {
						continue
					}
					o.AccessPoints = append(o.AccessPoints, outRadio{
						IP:   e.Addr,
						MAC:  e.MAC,
						Name: names[e.Addr],
					})
				}
			}
		}
		sortRadiosByIP(o.AccessPoints)

		// Switches: LLDP/CDP neighbors with bridge capability that aren't
		// AP radios, customer CPE, or other routers we already track.
		// Switches attach to the primary site (the one with `router:`).
		if s.Router != "" {
			if h, err := netip.ParseAddr(s.Router); err == nil {
				if d := byHost[h]; d != nil {
					o.Switches = collectSwitches(d, routerIPs)
				}
			}
		}

		addOut(o)
	}

	// Synthesize sites for any mgmt /20 we discovered that wasn't in seed.
	for _, d := range discoveries {
		for _, mgmt := range d.mgmtSubnets() {
			if _, claimed := siteByMgmt[mgmt]; claimed {
				continue
			}
			synthName := fmt.Sprintf("auto-%s", strings.ReplaceAll(mgmt.String(), "/", "_"))
			if seenSites[synthName] {
				continue
			}
			log.Printf("[inventory] new mgmt subnet %s on %s — emitted as site %q",
				mgmt, d.Router.Name, synthName)
			o := &outSite{
				Name:       synthName,
				Router:     d.Router.Host,
				MgmtSubnet: mgmt,
			}
			ap24 := topSlash24(mgmt)
			for _, e := range d.ARP {
				if !ap24.Contains(e.Addr) || routerIPs[e.Addr] {
					continue
				}
				if d.inCustomerPool(e.Addr) {
					continue
				}
				o.AccessPoints = append(o.AccessPoints, outRadio{
					IP:   e.Addr,
					MAC:  e.MAC,
					Name: names[e.Addr],
				})
			}
			sortRadiosByIP(o.AccessPoints)
			o.Switches = collectSwitches(d, routerIPs)
			addOut(o)
		}
	}

	// Backhaul radios: for each /29 with ≥2 owning routers, emit each radio
	// to the site whose router-IP in that /29 is closest. Only sites with
	// `router:` set can own a backhaul (satellites can't).
	for pfx, po := range bh29 {
		if len(po.routers) < 2 {
			continue // single-router /29 is something else (switch mgmt etc.)
		}
		// Gather radio candidates: the intersection of (ARP in this /29) on
		// any owning router, minus router-owned IPs.
		seenIP := map[netip.Addr]arpEntry{}
		for _, d := range po.routers {
			for _, e := range d.ARP {
				if !pfx.Contains(e.Addr) {
					continue
				}
				if routerIPs[e.Addr] {
					continue
				}
				if _, ok := seenIP[e.Addr]; !ok {
					seenIP[e.Addr] = e
				}
			}
		}
		// Routers with their own IP in this /29 → eligible owners.
		owners := map[netip.Addr]*routerDiscovery{}
		for _, d := range po.routers {
			for _, a := range d.Addresses {
				if pfx.Contains(a.Addr) && a.Prefix.Bits() == 29 {
					owners[a.Addr] = d
					break
				}
			}
		}

		// Determine the link label by consulting ANY seed site that
		// references this /29 — labels were collected per-site but the
		// /29 is the natural key.
		linkLabel := ""
		for _, ss := range seed.Sites {
			if l, ok := ss.LinkLabels[pfx]; ok {
				linkLabel = l
				break
			}
		}
		if linkLabel == "" {
			linkLabel = "TODO"
		}

		for radioIP, e := range seenIP {
			ownerHost := closestRouterIP(radioIP, owners)
			if !ownerHost.IsValid() {
				continue
			}
			d := owners[ownerHost]
			ownerSite := primarySiteForRouter(seed, d.Router.Host)
			if ownerSite == "" {
				log.Printf("[radios] backhaul %s on %s: no primary site has router=%s",
					radioIP, d.Router.Name, d.Router.Host)
				continue
			}
			// Find the outSite to append to.
			var target *outSite
			for _, o := range out {
				if o.Name == ownerSite {
					target = o
					break
				}
			}
			if target == nil {
				continue
			}
			target.BackhaulRadios = append(target.BackhaulRadios, outRadio{
				IP:   radioIP,
				MAC:  e.MAC,
				Name: names[radioIP],
				Link: linkLabel,
			})
		}
	}
	for _, o := range out {
		sortRadiosByIP(o.BackhaulRadios)
	}

	return out
}

// closestRouterIP returns the key in `owners` whose IP is numerically closest
// to `radio` within the /29.
func closestRouterIP(radio netip.Addr, owners map[netip.Addr]*routerDiscovery) netip.Addr {
	var best netip.Addr
	bestDist := -1
	rb := radio.As4()
	for o := range owners {
		ob := o.As4()
		dist := abs(int(rb[3]) - int(ob[3]))
		if bestDist < 0 || dist < bestDist {
			bestDist = dist
			best = o
		}
	}
	return best
}

func abs(x int) int {
	if x < 0 {
		return -x
	}
	return x
}

// primarySiteForRouter finds the seed site whose `router:` matches the given
// host, and returns its name. Empty if none.
func primarySiteForRouter(s *seed, host string) string {
	for _, name := range s.Order {
		if s.Sites[name].Router == host {
			return name
		}
	}
	return ""
}

func sortRadiosByIP(r []outRadio) {
	sort.Slice(r, func(i, j int) bool { return r[i].IP.Less(r[j].IP) })
}

// ---------------------------------------------------------------------------
// yaml rendering

// scalar makes a plain scalar yaml.Node.
func scalar(s string) *yaml.Node {
	return &yaml.Node{Kind: yaml.ScalarNode, Value: s, Tag: "!!str"}
}

// nullNode renders as `null` (untagged).
func nullNode() *yaml.Node {
	return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}
}

// stringOrNull picks between a !!str scalar and a null scalar.
func stringOrNull(s string) *yaml.Node {
	if s == "" {
		return nullNode()
	}
	return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: s, Style: yaml.DoubleQuotedStyle}
}

func plainScalar(v string) *yaml.Node {
	return &yaml.Node{Kind: yaml.ScalarNode, Value: v}
}

// radioFlow renders one access-point or backhaul entry as a flow-style
// mapping, matching the existing radios.yaml style.
func radioFlow(r outRadio) *yaml.Node {
	n := &yaml.Node{Kind: yaml.MappingNode, Style: yaml.FlowStyle}
	n.Content = append(n.Content, plainScalar("ip"), plainScalar(r.IP.String()))
	n.Content = append(n.Content, plainScalar("mac"), stringOrNull(r.MAC))
	n.Content = append(n.Content, plainScalar("name"), stringOrNull(r.Name))
	if r.Link != "" {
		n.Content = append(n.Content, plainScalar("link"), &yaml.Node{
			Kind: yaml.ScalarNode, Tag: "!!str", Value: r.Link, Style: yaml.DoubleQuotedStyle,
		})
	}
	return n
}

// cgnatFlow renders {subnet, gateway} as flow-style.
func cgnatFlow(c cgnat) *yaml.Node {
	n := &yaml.Node{Kind: yaml.MappingNode, Style: yaml.FlowStyle}
	n.Content = append(n.Content,
		plainScalar("subnet"), plainScalar(c.Subnet.String()),
		plainScalar("gateway"), plainScalar(c.Gateway.String()),
	)
	return n
}

// switchFlow renders one switch entry as a flow-style mapping.
func switchFlow(s outSwitch) *yaml.Node {
	n := &yaml.Node{Kind: yaml.MappingNode, Style: yaml.FlowStyle}
	n.Content = append(n.Content, plainScalar("ip"), plainScalar(s.IP.String()))
	n.Content = append(n.Content, plainScalar("mac"), stringOrNull(s.MAC))
	n.Content = append(n.Content, plainScalar("name"), stringOrNull(s.Name))
	n.Content = append(n.Content, plainScalar("model"), stringOrNull(s.Model))
	return n
}

func switchesBlock(items []outSwitch) *yaml.Node {
	n := &yaml.Node{Kind: yaml.SequenceNode}
	for _, s := range items {
		n.Content = append(n.Content, switchFlow(s))
	}
	return n
}

func radiosBlock(items []outRadio) *yaml.Node {
	n := &yaml.Node{Kind: yaml.SequenceNode}
	for _, r := range items {
		n.Content = append(n.Content, radioFlow(r))
	}
	return n
}

// siteToNode turns one outSite into the yaml mapping value for it.
func siteToNode(s *outSite) *yaml.Node {
	m := &yaml.Node{Kind: yaml.MappingNode}
	add := func(k string, v *yaml.Node) {
		m.Content = append(m.Content, plainScalar(k), v)
	}
	if s.Router != "" {
		add("router", plainScalar(s.Router))
	}
	if s.ParentRouter != "" {
		add("parent_router", plainScalar(s.ParentRouter))
	}
	if s.MgmtSubnet.IsValid() {
		add("mgmt_subnet", plainScalar(s.MgmtSubnet.String()))
	}
	switch len(s.Cgnats) {
	case 0:
		// nothing
	case 1:
		add("cgnat_subnet", cgnatFlow(s.Cgnats[0]))
	default:
		seq := &yaml.Node{Kind: yaml.SequenceNode}
		for _, c := range s.Cgnats {
			seq.Content = append(seq.Content, cgnatFlow(c))
		}
		add("cgnat_subnets", seq)
	}
	if len(s.AccessPoints) > 0 {
		add("access_points", radiosBlock(s.AccessPoints))
	}
	if len(s.BackhaulRadios) > 0 {
		add("backhaul_radios", radiosBlock(s.BackhaulRadios))
	}
	if len(s.Switches) > 0 {
		add("switches", switchesBlock(s.Switches))
	}
	return m
}

func renderYAML(s *seed, sites []*outSite) ([]byte, error) {
	root := &yaml.Node{Kind: yaml.DocumentNode}
	doc := &yaml.Node{Kind: yaml.MappingNode}
	root.Content = []*yaml.Node{doc}

	sitesMap := &yaml.Node{Kind: yaml.MappingNode}
	for _, o := range sites {
		// Site key: quote names that look like numbers (e.g., "982", "494").
		keyStyle := yaml.Style(0)
		if _, err := fmt.Sscan(o.Name, new(int)); err == nil {
			keyStyle = yaml.DoubleQuotedStyle
		}
		key := &yaml.Node{Kind: yaml.ScalarNode, Value: o.Name, Style: keyStyle, Tag: "!!str"}
		sitesMap.Content = append(sitesMap.Content, key, siteToNode(o))
	}
	doc.Content = append(doc.Content, plainScalar("sites"), sitesMap)

	var buf bytes.Buffer
	enc := yaml.NewEncoder(&buf)
	enc.SetIndent(2)
	if err := enc.Encode(root); err != nil {
		return nil, err
	}
	if err := enc.Close(); err != nil {
		return nil, err
	}

	var final bytes.Buffer
	if len(s.Header) > 0 {
		final.Write(s.Header)
		if !bytes.HasSuffix(s.Header, []byte("\n")) {
			final.WriteByte('\n')
		}
	}
	final.Write(buf.Bytes())
	return final.Bytes(), nil
}

// ---------------------------------------------------------------------------
// driver

func runInventory(cfg *Config) {
	seedData, err := loadSeedInventory(inventoryPath)
	if err != nil {
		log.Fatalf("seed: %v", err)
	}

	// Discover all routers in parallel.
	discoveries := make([]*routerDiscovery, len(cfg.Routers))
	var wg sync.WaitGroup
	for i, r := range cfg.Routers {
		wg.Add(1)
		go func(i int, r Router) {
			defer wg.Done()
			d, err := discoverRouter(cfg, r)
			if err != nil {
				log.Printf("[%s] discovery: %v", r.Name, err)
				return
			}
			discoveries[i] = d
			log.Printf("[%s] discovered %d addrs, %d arp, %d neighbors, %d customer pools",
				r.Name, len(d.Addresses), len(d.ARP), len(d.Neighbors), len(d.Customer))
		}(i, r)
	}
	wg.Wait()

	// Drop nil entries from failed routers.
	live := discoveries[:0]
	for _, d := range discoveries {
		if d != nil {
			live = append(live, d)
		}
	}
	if len(live) == 0 {
		log.Fatal("no routers reachable")
	}

	// Collect candidate radio IPs: AP candidates (top /24 of each mgmt /20)
	// + backhaul candidates (each /29 owned by ≥2 routers).
	routerIPs := map[netip.Addr]bool{}
	for _, d := range live {
		routerIPs[d.OwnIP] = true
		for _, a := range d.Addresses {
			routerIPs[a.Addr] = true
		}
	}

	bhCount := map[netip.Prefix]int{}
	for _, d := range live {
		for _, p := range d.backhaulPrefixes() {
			bhCount[p.Masked()]++
		}
	}

	candSet := map[netip.Addr]bool{}
	for _, d := range live {
		for _, mgmt := range d.mgmtSubnets() {
			ap24 := topSlash24(mgmt)
			for _, e := range d.ARP {
				if ap24.Contains(e.Addr) && !routerIPs[e.Addr] {
					candSet[e.Addr] = true
				}
			}
		}
		for _, p := range d.backhaulPrefixes() {
			if bhCount[p.Masked()] < 2 {
				continue
			}
			for _, e := range d.ARP {
				if p.Contains(e.Addr) && !routerIPs[e.Addr] {
					candSet[e.Addr] = true
				}
			}
		}
	}
	candIPs := make([]netip.Addr, 0, len(candSet))
	for ip := range candSet {
		candIPs = append(candIPs, ip)
	}
	sort.Slice(candIPs, func(i, j int) bool { return candIPs[i].Less(candIPs[j]) })
	log.Printf("[inventory] probing %d candidate radios via SNMP", len(candIPs))

	names := probeAll(candIPs)

	sites := assembleSites(seedData, live, names)

	body, err := renderYAML(seedData, sites)
	if err != nil {
		log.Fatalf("render: %v", err)
	}

	tmp := inventoryPath + ".tmp"
	if err := os.WriteFile(tmp, body, 0o644); err != nil {
		log.Fatalf("write %s: %v", tmp, err)
	}
	if err := os.Rename(tmp, inventoryPath); err != nil {
		log.Fatalf("rename %s: %v", inventoryPath, err)
	}
	log.Printf("[inventory] wrote %s (%d bytes, %d sites)", inventoryPath, len(body), len(sites))
}
