#!/usr/bin/env python3
"""Export an NS1 zone as Route 53 change batches. Python 3 stdlib only.

usage: NS1_API_KEY=... python3 ns1-to-route53.py example.com [outdir]

Writes to outdir (default: <zone>-route53/):
  batch-001.json ...  inputs for `aws route53 change-resource-record-sets --change-batch file://...`
  records.txt         "name type" for every record, for dns-diff.sh
  manual.txt          records that need hand-rebuilding (traffic steering, linked records, ALIAS, ...)
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request

API = "https://api.nsone.net/v1"
SUPPORTED = {"A", "AAAA", "CAA", "CNAME", "DS", "HTTPS", "MX", "NAPTR", "NS", "PTR",
             "SPF", "SRV", "SSHFP", "SVCB", "TLSA", "TXT"}
# Route 53 limits per ChangeResourceRecordSets call (with headroom).
MAX_VALUES, MAX_CHARS = 900, 30000


def get(path):
    req = urllib.request.Request(API + path, headers={"X-NSONE-Key": os.environ["NS1_API_KEY"]})
    for attempt in range(6):
        try:
            with urllib.request.urlopen(req) as r:
                return json.load(r)
        except urllib.error.HTTPError as e:
            if e.code != 429 or attempt == 5:
                raise
            time.sleep(2 ** attempt)  # NS1 rate limits per endpoint; back off and retry


def quote(s):
    return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'


def value(rtype, rdata):
    if rtype in ("TXT", "SPF"):
        # DNS character strings are limited to 255 bytes, not 255 Unicode characters.
        s = "".join(map(str, rdata))
        chunks, chunk, size = [], "", 0
        for char in s:
            width = len(char.encode("utf-8"))
            if size + width > 255:
                chunks.append(chunk)
                chunk, size = "", 0
            chunk += char
            size += width
        chunks.append(chunk)
        return " ".join(quote(chunk) for chunk in chunks)
    if rtype == "CAA":
        flags, tag, val = rdata
        return f"{flags} {tag} {quote(str(val))}"
    return " ".join(map(str, rdata))


def batches(changes):
    batch, values, chars = [], 0, 0
    for c in changes:
        rrs = c["ResourceRecordSet"]["ResourceRecords"]
        n, size = len(rrs), sum(len(r["Value"]) for r in rrs)
        if n > MAX_VALUES or size > MAX_CHARS:
            raise ValueError(f"Record set {c['ResourceRecordSet']['Name']} exceeds safe batch limits; import it manually")
        if batch and (values + n > MAX_VALUES or chars + size > MAX_CHARS):
            yield batch
            batch, values, chars = [], 0, 0
        batch.append(c)
        values += n
        chars += size
    if batch:
        yield batch


def main():
    zone = sys.argv[1].rstrip(".")
    out = sys.argv[2] if len(sys.argv) > 2 else f"{zone}-route53"
    os.makedirs(out, exist_ok=True)
    if any(name in ("records.txt", "manual.txt") or (name.startswith("batch-") and name.endswith(".json"))
           for name in os.listdir(out)):
        raise SystemExit(f"{out}: previous export found; choose a fresh output directory to avoid stale batches")

    changes, manual, listing = [], [], []
    for rec in get(f"/zones/{zone}")["records"]:
        name, rtype = rec["domain"], rec["type"]
        if rtype == "SOA" or (rtype == "NS" and name == zone):
            continue  # Route 53 creates its own SOA and apex NS
        listing.append(f"{name} {rtype}")
        full = get(f"/zones/{zone}/{name}/{rtype}")

        reason = None
        if full.get("link"):
            reason = f"linked record -> {full['link']}"
        elif full.get("filters"):
            reason = "traffic steering: " + ", ".join(f["filter"] for f in full["filters"])
        elif rtype == "ALIAS":
            reason = "ALIAS: use a Route 53 alias (AWS targets only) or plain A/AAAA"
        elif rtype not in SUPPORTED:
            reason = f"type {rtype} not supported by Route 53"
        if reason:
            manual.append(f"{name} {rtype}: {reason}")
            continue

        changes.append({
            "Action": "CREATE",
            "ResourceRecordSet": {
                "Name": name,
                "Type": rtype,
                "TTL": full["ttl"],
                "ResourceRecords": [{"Value": value(rtype, a["answer"])} for a in full["answers"]],
            },
        })

    for i, batch in enumerate(batches(changes), 1):
        with open(os.path.join(out, f"batch-{i:03}.json"), "w") as f:
            json.dump({"Comment": f"Import {zone} from NS1 ({i})", "Changes": batch}, f, indent=2)
    with open(os.path.join(out, "records.txt"), "w") as f:
        f.write("\n".join(listing) + "\n")
    with open(os.path.join(out, "manual.txt"), "w") as f:
        f.write("\n".join(manual) + ("\n" if manual else ""))

    print(f"{len(changes)} record sets converted, {len(manual)} need manual work -> {out}/")
    for m in manual:
        print("  MANUAL", m)


if __name__ == "__main__":
    main()
