#!/usr/bin/env python3
"""
Read targets JSON from stdin, write aggregated targets JSON to stdout.
Stats go to stderr so they don't pollute the output stream.

Pure-Python binary-trie implementation. Functionally identical to the
netaddr/IPSet reference, ~1000x faster on 16k-entry inputs.

Exit codes:
  0 - success
  1 - invalid JSON on stdin
  2 - unexpected error during aggregation
"""
import ipaddress
import json
import os
import random
import sys
import traceback
from collections import defaultdict


# ---- canonical config key (matches reference exactly) ----

def canonical(value):
    if isinstance(value, dict):
        return ("dict", tuple(sorted((k, canonical(v)) for k, v in value.items())))
    if isinstance(value, list):
        return ("list", tuple(canonical(v) for v in value))
    return value


def config_key(cfg):
    return tuple(sorted((k, canonical(v)) for k, v in cfg.items()))


# ---- CIDR parsing ----

def parse_cidr(s):
    net = ipaddress.ip_network(s, strict=False)
    family_bits = 32 if isinstance(net, ipaddress.IPv4Network) else 128
    return int(net.network_address), net.prefixlen, family_bits


def cidr_str_v4(ip_int, plen):
    return f"{(ip_int >> 24) & 0xFF}.{(ip_int >> 16) & 0xFF}.{(ip_int >> 8) & 0xFF}.{ip_int & 0xFF}/{plen}"


def cidr_str_v6(ip_int, plen):
    return f"{ipaddress.IPv6Address(ip_int)}/{plen}"


# ---- trie ----

class Node:
    __slots__ = ("left", "right", "entry_idx")
    def __init__(self):
        self.left = None
        self.right = None
        self.entry_idx = -1


def _build_trie(entries, family_bits):
    root = Node()
    for local_idx, (ip_int, plen, _, _) in enumerate(entries):
        node = root
        for d in range(plen):
            bit = (ip_int >> (family_bits - 1 - d)) & 1
            if bit:
                if node.right is None:
                    node.right = Node()
                node = node.right
            else:
                if node.left is None:
                    node.left = Node()
                node = node.left
        node.entry_idx = local_idx
    return root


def _collect_owned(root, entries, family_bits, keys):
    """
    Walk trie; emit per_config[key] = [(ip_int, plen), ...] for the maximal
    CIDRs each entry "owns" (subtree minus subtrees of deeper entries).
    Also returns the alive[] mask (entries that owned at least one IP).
    """
    alive = [False] * len(entries)
    per_config = defaultdict(list)

    # Iterative DFS. Stack entries: (node, prefix_bits, depth, anc_idx).
    # node may be None — that's a "missing slot" emit point for the
    # closest ancestor entry's config.
    stack = [(root, 0, 0, -1)]
    while stack:
        node, prefix_bits, depth, anc_idx = stack.pop()

        if node is None:
            if anc_idx != -1:
                per_config[keys[anc_idx]].append((prefix_bits << (family_bits - depth), depth))
                alive[anc_idx] = True
            continue

        eff_idx = node.entry_idx if node.entry_idx != -1 else anc_idx

        if node.left is None and node.right is None:
            if eff_idx != -1:
                per_config[keys[eff_idx]].append((prefix_bits << (family_bits - depth), depth))
                alive[eff_idx] = True
            continue

        # depth < family_bits guaranteed (leaves at family_bits have no children)
        stack.append((node.right, (prefix_bits << 1) | 1, depth + 1, eff_idx))
        stack.append((node.left, prefix_bits << 1, depth + 1, eff_idx))

    return per_config, alive


def minimal_cover(cidrs, family_bits):
    """
    Given disjoint CIDRs, return the minimal cover by merging sibling pairs
    upward. Output set has no two CIDRs that are siblings.
    """
    by_depth = defaultdict(set)
    for ip, d in cidrs:
        by_depth[d].add(ip)

    for d in range(family_bits, 0, -1):
        s = by_depth.get(d)
        if not s:
            continue
        sibling_bit = 1 << (family_bits - d)
        parent_mask = ~sibling_bit
        unpaired = set()
        merged_up = set()
        seen = set()
        for ip in s:
            if ip in seen:
                continue
            sib = ip ^ sibling_bit
            seen.add(ip)
            if sib in s:
                seen.add(sib)
                merged_up.add(ip & parent_mask)
            else:
                unpaired.add(ip)
        by_depth[d] = unpaired
        if merged_up:
            tgt = by_depth.get(d - 1)
            if tgt is None:
                by_depth[d - 1] = merged_up
            else:
                tgt.update(merged_up)

    out = []
    for d, s in by_depth.items():
        for ip in s:
            out.append((ip, d))
    return out


def _aggregate_family(entries, family_bits):
    if not entries:
        return [], set()

    root = _build_trie(entries, family_bits)

    keys = [config_key(e[2]) for e in entries]

    # Pick the representative cfg for each key the same way the reference
    # does: longest-prefix-first, ties broken by ascending network int.
    cfg_for_key = {}
    for i in sorted(range(len(entries)), key=lambda i: (-entries[i][1], entries[i][0])):
        cfg_for_key.setdefault(keys[i], entries[i][2])

    per_config, alive = _collect_owned(root, entries, family_bits, keys)

    out = []
    str_fn = cidr_str_v4 if family_bits == 32 else cidr_str_v6
    for k, cidrs in per_config.items():
        cfg = cfg_for_key[k]
        for ip_int, plen in minimal_cover(cidrs, family_bits):
            out.append((str_fn(ip_int, plen), cfg))

    dead = {entries[i][3] for i, a in enumerate(alive) if not a}
    return out, dead


def aggregate(targets):
    v4, v6 = [], []
    for original_idx, (cidr, cfg) in enumerate(targets.items()):
        ip_int, plen, family_bits = parse_cidr(cidr)
        rec = (ip_int, plen, cfg, original_idx)
        (v4 if family_bits == 32 else v6).append(rec)

    out_v4, dead_v4 = _aggregate_family(v4, 32)
    out_v6, dead_v6 = _aggregate_family(v6, 128)

    result = {}
    for cidr_s, cfg in out_v4:
        result[cidr_s] = cfg
    for cidr_s, cfg in out_v6:
        result[cidr_s] = cfg

    unique_keys = {config_key(cfg) for cfg in result.values()}
    stats = {
        "input": len(targets),
        "output": len(result),
        "configs": len(unique_keys),
        "dead_entries": len(dead_v4) + len(dead_v6),
    }
    return result, stats

# ---- main ----

def main():
    # Stage 1: read & parse stdin.
    try:
        targets = json.load(sys.stdin)
    except json.JSONDecodeError as e:
        sys.stderr.write(json.dumps({"error": "invalid_json", "message": str(e)}))
        return 1

    # Stage 2: aggregate.
    try:
        aggregated, stats = aggregate(targets)
    except Exception as e:
        sys.stderr.write(json.dumps({
            "error": "aggregation_failed",
            "message": str(e),
            "trace": traceback.format_exc(),
        }))
        return 3

    # Stage 3: emit output.
    json.dump(aggregated, sys.stdout, separators=(",", ":"), sort_keys=True)
    sys.stderr.write(json.dumps(stats))
    return 0


if __name__ == "__main__":
    sys.exit(main())