#!/usr/bin/env python3
"""
netbox_v6_sync.py — keep NetBox in sync with the live IPv6 state on the fleet.

What it does:
  1. Updates prefix descriptions + statuses for known v6 prefixes (so
     reading NetBox tells you what's actually live).
  2. For each router with v6 configured today (verona, climax, core),
     pulls /ipv6/address/print over the router API and creates/updates
     matching NetBox IPAM IP-address records, assigned to the correct
     device + interface.
  3. Associates prefixes to sites where the link is wholly within one
     site (mgmt LAN, customer PD), and leaves cross-site backbone P2Ps
     unsited.

It is idempotent — re-running it won't duplicate IPs; it'll just update
descriptions if anything has changed. Re-run after each new tower's v6
apply.

Usage:
    python3 netbox_v6_sync.py [--dry-run]

Auth: reads NETBOX_TOKEN (or NETBOX_KEY as fallback) from env. Needs the
RouterOS API creds in routers.yaml (uses the same `mikrotik-tool api`
binary in this directory).
"""

import json
import os
import subprocess
import sys
import urllib.error
import urllib.request

DRY_RUN = "--dry-run" in sys.argv
NETBOX = "https://netbox.vntx.net/api"
TOKEN = os.environ.get("NETBOX_TOKEN") or os.environ.get("NETBOX_KEY") \
    or "e50298f7fd20f7fd6f1931f635511b34f6e8cfde"

# Routers that have v6 configured (extend as more towers come online).
# Each tuple: (router-name-in-routers.yaml, netbox-device-name, site-slug)
ROUTERS = [
    ("verona",   "verona-router",      "verona"),
    ("climax",   "climax-router",      "climax"),
    ("core",     "380-core-router",    "380"),
]

# Prefix updates: (prefix, status, description)
PREFIX_UPDATES = [
    ("2606:1c80:0:10::/64",   "active",
     "Router loopbacks — one /128 per router (verona=:101, climax=:102, core=:253; others as towers come online)"),
    ("2606:1c80:0:11::/64",   "active",
     "P2P edge↔core direct (sfp-sfpplus7-core-direct ↔ ether3-edge-direct); BACKUP path (v6 distance 2)"),
    ("2606:1c80:0:12::/64",   "active",
     "P2P core↔climax (ether5-climax ↔ ether4-380-airfiber24); both ends /64"),
    ("2606:1c80:0:20::/64",   "active",
     "P2P verona↔climax 11GHz (ether3-climax-11ghz ↔ ether6-verona-11ghz); both ends /64"),
    ("2606:1c80:0:1002::/64", "active",
     "P2P edge↔core via Preseem inline shaper (transparent L2); PRIMARY path (v6 distance 1). Preseem rate-limits customer traffic per plan tier"),
    ("2606:1c80:1000::/64",   "active",
     "verona mgmt LAN (gateway 2606:1c80:1000::1 on bridge `verona`)"),
    ("2606:1c80:1100::/64",   "active",
     "climax mgmt LAN (gateway 2606:1c80:1100::1 on bridge `mgmt`)"),
    ("2606:1c80:1400::/64",   "active",
     "core/380 mgmt LAN (gateway 2606:1c80:1400::1 on vlan10_combo1)"),
    ("2606:1c80:1500::/64",   "active",
     "altoga mgmt LAN, served from verona (gateway 2606:1c80:1500::1 on ether6-switch)"),
    ("2606:1c80:1001::/48",   "active",
     "verona customer PD pool (256 × /56) — live in pool `verona-cust-pd-1`"),
    ("2606:1c80:1101::/48",   "active",
     "climax customer PD pool (256 × /56) — live in pool `climax-cust-pd-1`"),
    ("2606:1c80:1401::/48",   "active",
     "core/380 customer PD pool (256 × /56) — live in pool `core-cust-pd-1`"),
    ("2606:1c80:1501::/48",   "active",
     "altoga customer PD pool (256 × /56) — live in pool `altoga-cust-pd-1` on verona"),
]

# Site-attached prefixes (mgmt LAN + customer PD that map cleanly to one site)
PREFIX_SITES = {
    "2606:1c80:1000::/64":  "verona",
    "2606:1c80:1001::/48":  "verona",
    "2606:1c80:1100::/64":  "climax",
    "2606:1c80:1101::/48":  "climax",
    "2606:1c80:1400::/64":  "380",
    "2606:1c80:1401::/48":  "380",
    "2606:1c80:1500::/64":  "altoga",
    "2606:1c80:1501::/48":  "altoga",
}


def nb(method, path, payload=None):
    headers = {"Authorization": f"Token {TOKEN}", "Accept": "application/json"}
    data = None
    if payload is not None:
        headers["Content-Type"] = "application/json"
        data = json.dumps(payload).encode()
    req = urllib.request.Request(f"{NETBOX}{path}", data=data, method=method,
                                 headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=20) as r:
            body = r.read()
            return r.status, (json.loads(body) if body else None)
    except urllib.error.HTTPError as e:
        return e.code, json.loads(e.read() or b"null")


def find_prefix_id(prefix):
    code, data = nb("GET", f"/ipam/prefixes/?prefix={prefix}")
    if code != 200 or not data["results"]:
        return None
    return data["results"][0]


def find_site_id(slug):
    code, data = nb("GET", f"/dcim/sites/?slug={slug}")
    if code != 200 or not data["results"]:
        return None
    return data["results"][0]["id"]


def find_interface_id(device_name, interface_name):
    code, data = nb("GET",
                    f"/dcim/interfaces/?device={device_name}&name={interface_name}")
    if code != 200 or not data["results"]:
        return None
    return data["results"][0]["id"]


def ensure_interface(device_name, interface_name):
    """Get or create a NetBox interface for this device. Picks a sensible
    type based on the interface name pattern. Returns the interface id."""
    iface_id = find_interface_id(device_name, interface_name)
    if iface_id is not None:
        return iface_id
    device_id = find_device_id(device_name)
    if device_id is None:
        return None
    # Pick type from name
    nm = interface_name.lower()
    if nm == "lo" or nm == "loopback" or nm.startswith("vlan") \
       or nm in ("verona", "mgmt") or nm.endswith("-bridge"):
        if_type = "virtual"
    elif nm.startswith("sfp"):
        if_type = "10gbase-x-sfpp"
    else:
        if_type = "1000base-t"
    payload = {
        "device": device_id,
        "name": interface_name,
        "type": if_type,
        "description": f"auto-created by netbox_v6_sync.py",
    }
    if DRY_RUN:
        print(f"    would CREATE iface {interface_name} ({if_type})")
        return None
    code, body = nb("POST", "/dcim/interfaces/", payload)
    if code >= 400:
        print(f"    iface CREATE failed: {code} {body}")
        return None
    return body["id"]


def find_device_id(device_name):
    code, data = nb("GET", f"/dcim/devices/?name={device_name}")
    if code != 200 or not data["results"]:
        return None
    return data["results"][0]["id"]


def upsert_ip(address, device_name, interface_name, description):
    """Create or update a NetBox IP-address record assigned to the given
    device+interface. address is in CIDR form (e.g. 2606:1c80:0:10::101/128).
    """
    code, data = nb("GET", f"/ipam/ip-addresses/?address={address}")
    if code != 200:
        return f"GET failed: {code}"
    iface_id = ensure_interface(device_name, interface_name)
    if iface_id is None:
        return f"interface {device_name}:{interface_name} could not be created"
    payload = {
        "address": address,
        "status": "active",
        "assigned_object_type": "dcim.interface",
        "assigned_object_id": iface_id,
        "description": description,
    }
    if data["results"]:
        existing = data["results"][0]
        if DRY_RUN:
            return f"would PATCH id={existing['id']}"
        c, _ = nb("PATCH", f"/ipam/ip-addresses/{existing['id']}/", payload)
        return f"PATCH id={existing['id']} -> {c}"
    if DRY_RUN:
        return "would CREATE"
    c, body = nb("POST", "/ipam/ip-addresses/", payload)
    return f"CREATE -> {c}" + (f"  err={body}" if c >= 400 else "")


def update_prefix(prefix, pstatus, description, site_slug=None):
    p = find_prefix_id(prefix)
    if not p:
        return f"prefix {prefix} not in NetBox"
    payload = {"status": pstatus, "description": description}
    if site_slug:
        site_id = find_site_id(site_slug)
        if site_id:
            payload["site"] = site_id
    if DRY_RUN:
        return f"would PATCH id={p['id']} status={pstatus} site={site_slug}"
    c, _ = nb("PATCH", f"/ipam/prefixes/{p['id']}/", payload)
    return f"PATCH id={p['id']} -> {c}"


def get_router_v6_addresses(router_name):
    """Returns list of (address-cidr, interface-name, comment) tuples."""
    cmd = ["./mikrotik-tool", "api", router_name,
           "/ipv6/address/print", "?dynamic=false"]
    out = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
    if out.returncode != 0:
        return []
    rows = []
    for line in out.stdout.strip().split("\n"):
        if not line.strip():
            continue
        kv = {}
        for part in line.split("  "):
            if "=" in part:
                k, _, v = part.partition("=")
                kv[k.strip()] = v.strip()
        addr = kv.get("address", "")
        iface = kv.get("interface", "")
        comment = kv.get("comment", "")
        if addr and iface:
            rows.append((addr, iface, comment))
    return rows


def main():
    print(f"netbox_v6_sync.py — {'DRY RUN' if DRY_RUN else 'LIVE'}")
    print()

    # 1. Prefix updates
    print("== updating prefix descriptions/statuses ==")
    for prefix, pstatus, desc in PREFIX_UPDATES:
        site_slug = PREFIX_SITES.get(prefix)
        result = update_prefix(prefix, pstatus, desc, site_slug)
        print(f"  {prefix:32s}  {result}")
    print()

    # 2. Per-router IP-address sync
    for router_name, device_name, _site in ROUTERS:
        print(f"== syncing IPv6 addresses for {router_name} ==")
        if find_device_id(device_name) is None:
            print(f"  device {device_name} not in NetBox — skipping")
            continue
        addrs = get_router_v6_addresses(router_name)
        if not addrs:
            print("  no v6 addresses (or API failed)")
            continue
        for cidr, iface, comment in addrs:
            # Skip auto-assigned link-local (auto-link-local=true)
            if cidr.lower().startswith("fe80:"):
                continue
            desc = comment or f"{router_name} {iface}"
            result = upsert_ip(cidr, device_name, iface, desc)
            print(f"  {cidr:42s} -> {iface:32s}  {result}")
        print()


if __name__ == "__main__":
    main()
