#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Core sync engine: Bunny edge IPs to FMC Dynamic Objects"""
import json, ipaddress, logging
from typing import List, Set, Iterable
import requests
from xml.etree import ElementTree as ET
from wingpy import CiscoFMC

log = logging.getLogger("bunny2fmc")
BUNNY_IPV4_URL = "https://bunnycdn.com/api/system/edgeserverlist"
BUNNY_IPV6_URL = "https://bunnycdn.com/api/system/edgeserverlist/ipv6"

def _parse_possible_formats(body: str) -> List[str]:
    try:
        root = ET.fromstring(body)
        vals = [el.text.strip() for el in root.findall(".//{*}string") if el.text]
        if vals: return vals
    except ET.ParseError: pass
    try:
        data = json.loads(body)
        if isinstance(data, list): return [str(x).strip() for x in data if str(x).strip()]
    except json.JSONDecodeError: pass
    return [ln.strip() for ln in body.splitlines() if ln.strip()]

def fetch_bunny_ips(include_ipv6: bool, verify_ssl: bool = False) -> Set[str]:
    sess = requests.Session()
    ips: Set[str] = set()
    endpoints = [BUNNY_IPV4_URL] + ([BUNNY_IPV6_URL] if include_ipv6 else [])
    for url in endpoints:
        log.info("Fetching %s", url)
        try:
            r = sess.get(url, timeout=60, verify=verify_ssl, headers={"Accept": "application/xml, application/json, text/plain"})
            r.raise_for_status()
            for c in _parse_possible_formats(r.text):
                try:
                    if "/" in c: net = ipaddress.ip_network(c, strict=False)
                    else:
                        ip = ipaddress.ip_address(c)
                        net = ipaddress.ip_network(f"{ip}/{32 if ip.version == 4 else 128}", strict=False)
                    ips.add(str(net))
                except ValueError: log.warning("Ignoring invalid Bunny entry: %r", c)
        except Exception as e:
            log.error("Error fetching %s: %s", url, e)
            raise
    if not ips: raise RuntimeError("No IPs fetched from Bunny")
    return ips

def get_all(fmc: CiscoFMC, path: str) -> List[dict]:
    items: List[dict] = []
    offset = 0
    while True:
        r = fmc.get(path + f"?offset={offset}&limit=1000&expanded=false")
        r.raise_for_status()
        batch = (r.json() or {}).get("items", [])
        items.extend(batch)
        if len(batch) < 1000: break
        offset += 1000
    return items

def chunked(iterable: Iterable[str], size: int) -> Iterable[List[str]]:
    buf = []
    for x in iterable:
        buf.append(x)
        if len(buf) == size: yield buf; buf = []
    if buf: yield buf

def find_or_create_dynamic_object(fmc: CiscoFMC, name: str, description: str = "", dry_run: bool = False) -> dict:
    items = get_all(fmc, "/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects")
    obj = next((it for it in items if it.get("name") == name), None)
    if obj:
        log.info("Found existing Dynamic Object: %s (id=%s)", name, obj.get("id"))
        return obj
    payload = {"type": "DynamicObject", "name": name, "objectType": "IP", "description": description or "Managed automatically from BunnyCDN edge server list."}
    if dry_run:
        log.info("[DRY_RUN] Would create Dynamic Object '%s'.", name)
        return {"id": "DRYRUN-ID", "name": name, "type": "DynamicObject", "objectType": "IP"}
    r = fmc.post("/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects", data=payload)
    r.raise_for_status()
    result = r.json()
    log.info("Created Dynamic Object: %s (id=%s)", name, result.get("id"))
    return result

def get_current_mappings(fmc: CiscoFMC, obj_id: str) -> Set[str]:
    r = fmc.get("/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects/{objectId}/mappings", path_params={"objectId": obj_id})
    r.raise_for_status()
    data = r.json() or {}
    if isinstance(data, dict):
        if "mappings" in data and isinstance(data["mappings"], list):
            return set(str(x).strip() for x in data["mappings"] if str(x).strip())
        if "items" in data and isinstance(data["items"], list):
            out = set()
            for it in data["items"]:
                if isinstance(it, str): out.add(it.strip())
                elif isinstance(it, dict) and "value" in it: out.add(str(it["value"]).strip())
            return out
    if isinstance(data, list): return set(str(x).strip() for x in data if str(x).strip())
    return set()

def post_mappings_update(fmc: CiscoFMC, add: List[str], remove: List[str], obj_id: str, chunk_size: int = 500, dry_run: bool = False):
    if dry_run:
        log.info("[DRY_RUN] Would ADD %d and REMOVE %d mappings.", len(add), len(remove))
        return
    endpoint = "/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjectmappings"
    for batch in chunked(add, chunk_size):
        payload = {"add": [{"mappings": batch, "dynamicObject": {"id": obj_id}}]}
        r = fmc.post(endpoint, data=payload)
        r.raise_for_status()
        log.info("Added %d mappings.", len(batch))
    for batch in chunked(remove, chunk_size):
        payload = {"remove": [{"mappings": batch, "dynamicObject": {"id": obj_id}}]}
        r = fmc.post(endpoint, data=payload)
        r.raise_for_status()
        log.info("Removed %d mappings.", len(batch))

def sync(fmc_base_url: str, fmc_username: str, fmc_password: str, fmc_dynamic_name: str, include_ipv6: bool = False, verify_ssl: bool = False, dry_run: bool = False, chunk_size: int = 500) -> dict:
    try:
        log.info("Starting sync for Dynamic Object: %s", fmc_dynamic_name)
        bunny_nets = fetch_bunny_ips(include_ipv6, verify_ssl)
        log.info("Bunny: %d networks", len(bunny_nets))
        fmc = CiscoFMC(base_url=fmc_base_url, username=fmc_username, password=fmc_password, verify=verify_ssl)
        dyn = find_or_create_dynamic_object(fmc, fmc_dynamic_name, description="Dynamic Object auto-managed from BunnyCDN edge server list.", dry_run=dry_run)
        dyn_id = dyn["id"]
        log.info("Dynamic Object: %s (id=%s)", fmc_dynamic_name, dyn_id)
        current = get_current_mappings(fmc, dyn_id)
        desired = bunny_nets
        to_add, to_remove = sorted(desired - current), sorted(current - desired)
        log.info("Current: %d, Desired: %d, +Add: %d, -Remove: %d", len(current), len(desired), len(to_add), len(to_remove))
        if not to_add and not to_remove:
            log.info("No changes needed.")
            return {"status": "success", "message": "No changes needed", "added": 0, "removed": 0, "total_current": len(current), "total_desired": len(desired)}
        post_mappings_update(fmc, to_add, to_remove, dyn_id, chunk_size, dry_run)
        log.info("Sync completed successfully.")
        return {"status": "success", "message": "Sync completed", "added": len(to_add), "removed": len(to_remove), "total_current": len(current), "total_desired": len(desired)}
    except Exception as e:
        log.error("Sync failed: %s", str(e), exc_info=True)
        return {"status": "error", "message": str(e), "added": 0, "removed": 0}
