#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Core sync engine: Bunny edge IPs to FMC Dynamic Objects

Uses wingpy's CiscoFMC client which supports authentication via:
- Direct parameters: CiscoFMC(base_url=..., username=..., password=...)
- Environment variables: WINGPY_FMC_BASE_URL, WINGPY_FMC_USERNAME, WINGPY_FMC_PASSWORD

See: https://wingpy.automation.wingmen.dk/faq/#environment-variables
"""
import json
import ipaddress
import logging
import os
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]:
    """Parse Bunny API response (XML, JSON, or plain text)"""
    # Try XML first
    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 JSON
    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

    # Fallback to plain text (one IP per line)
    return [ln.strip() for ln in body.splitlines() if ln.strip()]


def normalize_ip(ip_str: str) -> str:
    """
    Normalize IP address to plain IP format (no CIDR for single hosts).
    
    FMC Dynamic Object mappings store IPs without CIDR notation for /32 and /128.
    - Input: "1.2.3.4/32" or "1.2.3.4" → Output: "1.2.3.4"
    - Input: "2001:db8::1/128" → Output: "2001:db8::1"
    - Input: "10.0.0.0/24" → Output: "10.0.0.0/24" (keep CIDR for subnets)
    """
    ip_str = ip_str.strip()
    
    if "/" in ip_str:
        try:
            net = ipaddress.ip_network(ip_str, strict=False)
            # If it's a single host (/32 or /128), return just the IP
            if (net.version == 4 and net.prefixlen == 32) or \
               (net.version == 6 and net.prefixlen == 128):
                return str(net.network_address)
            # Otherwise keep the CIDR notation
            return str(net)
        except ValueError:
            return ip_str
    else:
        # Already plain IP
        return ip_str


def fetch_bunny_ips(include_ipv6: bool, verify_ssl: bool = False) -> Set[str]:
    """Fetch current Bunny CDN edge server IP addresses"""
    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:
                    # Parse and normalize the IP
                    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
                        )
                    # Normalize: single hosts without /32 or /128
                    ips.add(normalize_ip(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 chunked(iterable: Iterable[str], size: int) -> Iterable[List[str]]:
    """Split iterable into chunks of given size"""
    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:
    """Find existing Dynamic Object or create a new one"""
    # Use wingpy's get_all for automatic pagination
    items = fmc.get_all(
        "/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

    # Create new Dynamic Object
    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]:
    """
    Get current IP mappings for a Dynamic Object.
    
    FMC returns paginated results with format:
    {
        "items": [{"mapping": "1.2.3.4"}, {"mapping": "5.6.7.8"}],
        "paging": {"count": 594, "offset": 0, "limit": 25}
    }
    
    We need to fetch all pages and extract the "mapping" field.
    """
    mappings: Set[str] = set()
    offset = 0
    limit = 1000  # Max per page
    
    while True:
        r = fmc.get(
            "/api/fmc_config/v1/domain/{domainUUID}/object/dynamicobjects/{objectId}/mappings",
            path_params={"objectId": obj_id},
            params={"offset": offset, "limit": limit},
        )
        r.raise_for_status()
        data = r.json() or {}
        
        items = data.get("items", [])
        for item in items:
            if isinstance(item, dict):
                # FMC format: {"mapping": "1.2.3.4"}
                if "mapping" in item:
                    mappings.add(normalize_ip(str(item["mapping"])))
                # Alternative format: {"value": "1.2.3.4"}
                elif "value" in item:
                    mappings.add(normalize_ip(str(item["value"])))
            elif isinstance(item, str):
                mappings.add(normalize_ip(item))
        
        # Check if there are more pages
        paging = data.get("paging", {})
        total_count = paging.get("count", 0)
        
        if offset + len(items) >= total_count:
            break
        offset += limit
    
    log.debug("Retrieved %d current mappings from FMC", len(mappings))
    return mappings


def post_mappings_update(
    fmc: CiscoFMC,
    add: List[str],
    remove: List[str],
    obj_id: str,
    chunk_size: int = 500,
    dry_run: bool = False,
):
    """Add/remove IP mappings in chunks"""
    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:
    """
    Main sync function: Fetches Bunny IPs and updates FMC Dynamic Object.

    Authentication is handled by wingpy's CiscoFMC client:
    - First request: authenticates with username/password to get access token
    - Subsequent requests: uses access token automatically
    - Token refresh: handled automatically by wingpy

    See: https://wingpy.automation.wingmen.dk/faq/#session-maintenance
    """
    try:
        log.info("Starting sync for Dynamic Object: %s", fmc_dynamic_name)

        # Fetch current Bunny CDN IPs
        bunny_nets = fetch_bunny_ips(include_ipv6, verify_ssl)
        log.info("Bunny: %d networks", len(bunny_nets))

        # Connect to FMC using wingpy
        # wingpy handles token-based authentication automatically:
        # - First call: POST /api/fmc_platform/v1/auth/generatetoken with Basic Auth
        # - Returns X-auth-access-token (valid 30 min)
        # - All subsequent calls use this token
        # See: https://wingpy.automation.wingmen.dk/user-guide/fmc/
        fmc = CiscoFMC(
            base_url=fmc_base_url,
            username=fmc_username,
            password=fmc_password,
            verify=verify_ssl,
        )

        # Find or create the Dynamic Object
        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)

        # Get current mappings and calculate diff
        current = get_current_mappings(fmc, dyn_id)
        desired = bunny_nets
        to_add = sorted(desired - current)
        to_remove = 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),
            }

        # Apply changes
        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}
