#!/usr/bin/env python3
"""Sync MikroTik live PPPoE+DHCP state -> Gaiia MacAddressIpAddressAssignation.

Bypasses the broken Gaiia AWS webhook workflow that's supposed to keep
Gaiia's IP-assignment table in sync with reality. Calls the public-but-
hidden mutations:
    upsertMacAddressIpAddressAssignations(input)
    deactivateMacAddressIpAddressAssignations(input)
both of which are reachable via raw GraphQL even though they don't appear
in Mutation.fields introspection.

Defaults to dry-run. Use --apply to actually push changes.

Usage:
    GAIIA_KEY=... ./scripts/gaiia_ip_sync.py                  # dry-run
    GAIIA_KEY=... ./scripts/gaiia_ip_sync.py --apply          # do it
    GAIIA_KEY=... ./scripts/gaiia_ip_sync.py --routers 982    # one tower
    GAIIA_KEY=... ./scripts/gaiia_ip_sync.py --no-deactivate  # only upserts
"""
from __future__ import annotations

import argparse
import os
import re
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from typing import Any

REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(REPO, "gaiia", "src"))
from gaiia import GaiiaClient, GaiiaAPIError  # noqa: E402

TOWER_ROUTERS = ["verona", "climax", "culleoka", "newhope", "lowry", "982", "494", "core"]
MTT = os.path.join(REPO, "mikrotik-tool", "mikrotik-tool")

PAYING = {"ACTIVE", "TRIAL"}
CUSTOMER_DHCP_KEYWORDS = ("cpe", "cgnat", "customer", "hotspot")


# ---------- helpers ----------

def norm_mac(s: str) -> str:
    return re.sub(r"[^0-9a-f]", "", (s or "").lower())


def fmt_mac(s: str) -> str:
    n = norm_mac(s)
    return ":".join(n[i:i+2] for i in range(0, 12, 2)).upper() if len(n) == 12 else s


def smush(s: str) -> str:
    return re.sub(r"[^a-z0-9]", "", (s or "").lower())


def parse_kv_line(line: str) -> dict[str, str]:
    kv = {}
    for part in re.split(r"  +", line.strip()):
        if "=" in part:
            k, v = part.split("=", 1)
            kv[k] = v
    return kv


# ---------- MikroTik fetch ----------

def fetch_ppp_active(router: str) -> list[dict[str, Any]]:
    p = subprocess.run([MTT, "api", router, "/ppp/active/print"],
                       capture_output=True, text=True, timeout=90,
                       cwd=os.path.join(REPO, "mikrotik-tool"))
    if p.returncode != 0:
        print(f"!! {router} ppp active: {p.stderr.strip()[:200]}", file=sys.stderr)
        return []
    out = []
    for line in p.stdout.splitlines():
        if not line.startswith(".id="): continue
        kv = parse_kv_line(line)
        if kv.get("service") != "pppoe": continue
        out.append({
            "source": "pppoe",
            "router": router,
            "ip": kv.get("address", ""),
            "mac": norm_mac(kv.get("caller-id", "")),
            "mac_raw": kv.get("caller-id", ""),
            "username": kv.get("name", ""),
            "host": "",
            "uptime": kv.get("uptime", ""),
        })
    return out


def fetch_dhcp_leases(router: str) -> list[dict[str, Any]]:
    p = subprocess.run([MTT, "api", router, "/ip/dhcp-server/lease/print"],
                       capture_output=True, text=True, timeout=90,
                       cwd=os.path.join(REPO, "mikrotik-tool"))
    if p.returncode != 0:
        print(f"!! {router} dhcp leases: {p.stderr.strip()[:200]}", file=sys.stderr)
        return []
    out = []
    for line in p.stdout.splitlines():
        if not line.startswith(".id="): continue
        kv = parse_kv_line(line)
        if kv.get("status") != "bound": continue
        server = (kv.get("active-server") or kv.get("server") or "").lower()
        if not any(k in server for k in CUSTOMER_DHCP_KEYWORDS):
            continue
        mac = kv.get("active-mac-address") or kv.get("mac-address") or ""
        if not mac:
            continue
        out.append({
            "source": "dhcp",
            "router": router,
            "ip": kv.get("active-address") or kv.get("address") or "",
            "mac": norm_mac(mac),
            "mac_raw": mac,
            "username": "",
            "host": kv.get("host-name", ""),
            "server": server,
        })
    return out


def fetch_router_state(routers: list[str]) -> list[dict[str, Any]]:
    with ThreadPoolExecutor(max_workers=len(routers) * 2) as ex:
        futs = []
        for r in routers:
            futs.append(ex.submit(fetch_ppp_active, r))
            futs.append(ex.submit(fetch_dhcp_leases, r))
        sessions = []
        for f in futs:
            sessions.extend(f.result())
    return sessions


# ---------- Gaiia fetch ----------

def paginate(client: GaiiaClient, q_with: str, q_first: str, path: str):
    cur: str | None = None
    while True:
        d = client.query(q_first) if cur is None else client.query(q_with, {"after": cur})
        node = d
        for part in path.split("."):
            node = node[part]
        for n in (node.get("nodes") or []):
            yield n
        pi = node.get("pageInfo") or {}
        if not pi.get("hasNextPage"): return
        cur = pi.get("endCursor")
        if not cur: return


def fetch_gaiia(client: GaiiaClient):
    """Return (accounts_by_id, mac_to_inventory, ip_to_current_assignment, all_assignments)."""
    accounts: dict[str, dict] = {}
    q_acct_with = """query Q($after:String!){ accounts(first:100,after:$after){
      nodes{ id readableId displayName status{ name }
             billingSubscriptions(first:50){ nodes{ status } } }
      pageInfo{ hasNextPage endCursor } } }"""
    q_acct_first = """query{ accounts(first:100){
      nodes{ id readableId displayName status{ name }
             billingSubscriptions(first:50){ nodes{ status } } }
      pageInfo{ hasNextPage endCursor } } }"""
    for a in paginate(client, q_acct_with, q_acct_first, "accounts"):
        subs = [s["status"] for s in ((a.get("billingSubscriptions") or {}).get("nodes") or [])]
        accounts[a["id"]] = {
            "id": a["id"],
            "readable": a["readableId"],
            "name": a["displayName"],
            "status": a["status"]["name"],
            "paying": any(s in PAYING for s in subs),
            "subs": subs,
        }

    mac_to_inv: dict[str, dict] = {}
    ip_to_assn: dict[str, list[dict]] = {}
    all_assn: list[dict] = []
    q_inv_with = """query Q($after:String!){ inventoryItems(first:100,after:$after){
      nodes{ id ipAddressV4
        fields{ nodes{ data modelField{ name } } }
        assignation{ assigneeType assignee{ __typename ... on Account{ id } } }
        ipAddressAssignments(filter:{isActive:true}, first:50){
          nodes{ id ipAddress macAddress isActive type
                 account{ id readableId displayName } } }
      }
      pageInfo{ hasNextPage endCursor } } }"""
    q_inv_first = q_inv_with.replace("query Q($after:String!)", "query").replace(",after:$after", "")
    for it in paginate(client, q_inv_with, q_inv_first, "inventoryItems"):
        macs = []
        for f in ((it.get("fields") or {}).get("nodes") or []):
            mf = f.get("modelField") or {}
            if mf.get("name", "").lower().startswith("mac") and f.get("data"):
                m = norm_mac(f["data"])
                if len(m) == 12:
                    macs.append(m)
        assn = it.get("assignation") or {}
        assignee = assn.get("assignee") or {}
        acct_id = assignee["id"] if assignee.get("__typename") == "Account" else None
        for m in macs:
            rec = {"item_id": it["id"], "account_id": acct_id}
            if m in mac_to_inv and mac_to_inv[m]["account_id"]:
                continue
            mac_to_inv[m] = rec
        for ipa in (it.get("ipAddressAssignments") or {}).get("nodes") or []:
            if not ipa.get("ipAddress"): continue
            entry = {**ipa, "inventory_item_id": it["id"]}
            ip_to_assn.setdefault(ipa["ipAddress"], []).append(entry)
            all_assn.append(entry)

    return accounts, mac_to_inv, ip_to_assn, all_assn


# ---------- resolution ----------

def build_username_idx(accounts):
    idx: dict[str, list[str]] = {}
    for aid, a in accounts.items():
        k = smush(a["name"])
        if k:
            idx.setdefault(k, []).append(aid)
    return idx


def resolve_session(s, mac_to_inv, accounts, name_idx):
    """Return (account_id, inventory_item_id, match_via) or (None, None, reason)."""
    inv = mac_to_inv.get(s["mac"]) if s["mac"] else None
    if inv and inv["account_id"]:
        return inv["account_id"], inv["item_id"], "mac"
    # fallback: fuzzy displayName match on username (PPPoE) or host (DHCP)
    key = smush(s["username"] or s["host"])
    cands = name_idx.get(key, []) if key else []
    paying = [c for c in cands if accounts[c]["paying"]]
    if len(paying) == 1:
        return paying[0], inv["item_id"] if inv else None, "name-paying"
    if len(cands) == 1:
        return cands[0], inv["item_id"] if inv else None, "name"
    if not cands:
        return None, inv["item_id"] if inv else None, ("mac-unassigned" if inv else "unmatched")
    return None, inv["item_id"] if inv else None, "name-ambiguous"


# ---------- diff ----------

def plan(sessions, accounts, mac_to_inv, ip_to_assn):
    name_idx = build_username_idx(accounts)
    upserts = []
    skipped = []  # sessions we can't resolve
    seen_ips = set()

    for s in sessions:
        if ":" in s["ip"] or not s["ip"]:
            continue  # IPv6 / blank skip
        seen_ips.add(s["ip"])
        acct_id, item_id, via = resolve_session(s, mac_to_inv, accounts, name_idx)
        if not acct_id:
            skipped.append((s, via))
            continue
        current = ip_to_assn.get(s["ip"], [])
        # is there an exact-match active assignment already?
        match = None
        for c in current:
            same_mac = norm_mac(c["macAddress"]) == s["mac"]
            same_acct = (c.get("account") or {}).get("id") == acct_id
            same_item = c.get("inventory_item_id") == item_id if item_id else True
            if same_mac and same_acct and same_item:
                match = c
                break
        if match:
            continue  # already correct
        upserts.append({
            "session": s,
            "via": via,
            "account_id": acct_id,
            "inventory_item_id": item_id,
            "current": current,
        })

    # Deactivations: any IP that has an active Gaiia assignment but no live session for it
    deactivations = []
    for ip, assignments in ip_to_assn.items():
        if ip in seen_ips:
            continue
        for a in assignments:
            deactivations.append({"ip": ip, "id": a["id"], "current": a})

    return upserts, deactivations, skipped


# ---------- mutations ----------

def do_upserts(client, upserts):
    if not upserts:
        return
    BATCH = 50
    payload = []
    for u in upserts:
        s = u["session"]
        entry = {
            "ipAddress": s["ip"],
            "type": "IPV4",
            "macAddress": fmt_mac(s["mac"]),
            "accountId": u["account_id"],
            "expired": False,
        }
        if u["inventory_item_id"]:
            entry["inventoryItemId"] = u["inventory_item_id"]
        payload.append(entry)

    mut = """mutation M($i: UpsertMacAddressIpAddressAssignationsInput!) {
      upsertMacAddressIpAddressAssignations(input: $i) {
        assignations { id ipAddress macAddress account { readableId } }
        errors { code message }
      }
    }"""
    total_ok = 0
    for i in range(0, len(payload), BATCH):
        chunk = payload[i:i+BATCH]
        try:
            res = client.mutate(mut, "upsertMacAddressIpAddressAssignations",
                                {"i": {"assignations": chunk}})
            n = len(res.get("assignations") or [])
            total_ok += n
            print(f"  upsert batch {i//BATCH+1}: {n}/{len(chunk)} ok")
        except GaiiaAPIError as e:
            print(f"  upsert batch {i//BATCH+1}: ERROR {str(e)[:300]}", file=sys.stderr)
    print(f"upserts applied: {total_ok}/{len(payload)}")


def do_deactivates(client, deactivations):
    if not deactivations:
        return
    BATCH = 50
    ids = [d["id"] for d in deactivations]
    mut = """mutation M($i: DeactivateMacAddressIpAddressAssignationsInput!) {
      deactivateMacAddressIpAddressAssignations(input: $i) {
        assignations { id ipAddress }
        errors { code message }
      }
    }"""
    total_ok = 0
    for i in range(0, len(ids), BATCH):
        chunk = ids[i:i+BATCH]
        try:
            res = client.mutate(mut, "deactivateMacAddressIpAddressAssignations",
                                {"i": {"ids": chunk}})
            n = len(res.get("assignations") or [])
            total_ok += n
            print(f"  deactivate batch {i//BATCH+1}: {n}/{len(chunk)} ok")
        except GaiiaAPIError as e:
            print(f"  deactivate batch {i//BATCH+1}: ERROR {str(e)[:300]}", file=sys.stderr)
    print(f"deactivations applied: {total_ok}/{len(ids)}")


# ---------- main ----------

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--apply", action="store_true", help="actually call mutations (default: dry-run)")
    ap.add_argument("--routers", help="comma-separated subset")
    ap.add_argument("--no-deactivate", action="store_true", help="don't deactivate stale Gaiia assignments")
    ap.add_argument("--limit", type=int, default=0, help="cap upserts (for testing)")
    args = ap.parse_args()

    if not os.environ.get("GAIIA_KEY"):
        sys.exit("GAIIA_KEY not set")

    routers = args.routers.split(",") if args.routers else TOWER_ROUTERS
    print(f"Fetching live state from {len(routers)} routers...", file=sys.stderr)
    sessions = fetch_router_state(routers)
    print(f"  {len(sessions)} customer sessions "
          f"({sum(1 for s in sessions if s['source']=='pppoe')} pppoe, "
          f"{sum(1 for s in sessions if s['source']=='dhcp')} dhcp)", file=sys.stderr)

    print("Fetching Gaiia state...", file=sys.stderr)
    with GaiiaClient(timezone="America/Chicago") as g:
        accounts, mac_to_inv, ip_to_assn, all_assn = fetch_gaiia(g)
    paying = sum(1 for a in accounts.values() if a["paying"])
    print(f"  {len(accounts)} accounts ({paying} paying), "
          f"{len(mac_to_inv)} MACs in inventory, "
          f"{len(all_assn)} active IP assignments", file=sys.stderr)

    upserts, deactivations, skipped = plan(sessions, accounts, mac_to_inv, ip_to_assn)

    if args.limit:
        upserts = upserts[:args.limit]
        deactivations = deactivations[:args.limit]

    print()
    print(f"=== Plan ===")
    print(f"  {len(upserts)} upserts (Gaiia is wrong / missing)")
    print(f"  {len(deactivations)} deactivations (Gaiia has stale active assignment)")
    print(f"  {len(skipped)} sessions we couldn't resolve to an account (skipped)")
    if upserts:
        print("\n--- UPSERTS ---")
        for u in upserts[:200]:
            s = u["session"]
            acct = accounts.get(u["account_id"], {})
            cur = ""
            if u["current"]:
                c = u["current"][0]
                ca = (c.get("account") or {}).get("displayName") or "<none>"
                cur = f"   was: ip={c['ipAddress']} mac={c['macAddress']} acct={ca!r}"
            print(f"  {s['router']:9} {s['ip']:18} {fmt_mac(s['mac']):17} "
                  f"src={s['source']:5} user={(s['username'] or s['host'])!r:25} -> "
                  f"#{acct.get('readable')} {acct.get('name')!r} via={u['via']}")
            if cur: print(cur)
        if len(upserts) > 200:
            print(f"  ... and {len(upserts)-200} more")
    if deactivations:
        print("\n--- DEACTIVATIONS ---")
        for d in deactivations[:200]:
            c = d["current"]
            ca = (c.get("account") or {}).get("displayName") or "<none>"
            print(f"  ip={d['ip']} mac={c['macAddress']} acct={ca!r}")
        if len(deactivations) > 200:
            print(f"  ... and {len(deactivations)-200} more")
    if skipped:
        print("\n--- SKIPPED (couldn't resolve) ---")
        for s, why in skipped[:50]:
            print(f"  {s['router']:9} {s['ip']:18} {fmt_mac(s['mac']):17} "
                  f"src={s['source']:5} user={(s['username'] or s['host'])!r:25} reason={why}")
        if len(skipped) > 50:
            print(f"  ... and {len(skipped)-50} more")

    if not args.apply:
        print("\n(dry-run; pass --apply to execute)")
        return

    print("\n=== Applying ===")
    with GaiiaClient(timezone="America/Chicago") as g:
        do_upserts(g, upserts)
        if not args.no_deactivate:
            do_deactivates(g, deactivations)


if __name__ == "__main__":
    main()
