#!/usr/bin/env python3
"""
Namecheap DNS Batch Provisioner
Reads a JSON plan file and replaces all DNS records in one atomic operation.

CRITICAL: Namecheap setHosts OVERWRITES all records. This script:
  1. Backs up current records before any changes
  2. Validates the plan for common errors
  3. Performs a dry-run by default
  4. Requires explicit --apply flag to make changes

Usage:
    python namecheap_batch.py --domain gogenex.com --plan plan.json                 # dry-run (default)
    python namecheap_batch.py --domain gogenex.com --plan plan.json --apply         # apply changes
    python namecheap_batch.py --domain gogenex.com --plan plan.json --merge         # merge with existing
    python namecheap_batch.py --domain gogenex.com --diff plan.json                 # diff plan vs current
    python namecheap_batch.py --domain gogenex.com --export current.json            # export current state

Plan JSON format: Same as aliyun-dns skill (see references/plan-schema.md)

Environment Variables:
    NAMECHEAP_API_USER  - Namecheap username
    NAMECHEAP_API_KEY   - Namecheap API key
    NAMECHEAP_CLIENT_IP - Whitelisted IPv4 address
"""

import argparse
import ipaddress
import json
import os
import sys
import time
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime


# ──────────────────────────────────────────────
# Configuration & Helpers
# ──────────────────────────────────────────────

API_URL_PROD = "https://api.namecheap.com/xml.response"
API_URL_SANDBOX = "https://api.sandbox.namecheap.com/xml.response"

PRIVATE_RANGES = [
    ipaddress.ip_network("10.0.0.0/8"),
    ipaddress.ip_network("172.16.0.0/12"),
    ipaddress.ip_network("192.168.0.0/16"),
    ipaddress.ip_network("100.64.0.0/10"),
]

# Namecheap supported record types via API
SUPPORTED_TYPES = {"A", "AAAA", "CNAME", "MX", "MXE", "TXT", "URL", "URL301", "FRAME", "NS"}


def is_private_ip(ip_str: str) -> bool:
    try:
        ip = ipaddress.ip_address(ip_str)
        return any(ip in net for net in PRIVATE_RANGES)
    except ValueError:
        return False


def get_config(sandbox: bool = False):
    api_user = os.environ.get("NAMECHEAP_API_USER")
    api_key = os.environ.get("NAMECHEAP_API_KEY")
    client_ip = os.environ.get("NAMECHEAP_CLIENT_IP")

    if not all([api_user, api_key, client_ip]):
        print("ERROR: Set NAMECHEAP_API_USER, NAMECHEAP_API_KEY, and NAMECHEAP_CLIENT_IP env vars.")
        sys.exit(1)

    return {
        "api_user": api_user,
        "api_key": api_key,
        "client_ip": client_ip,
        "base_url": API_URL_SANDBOX if sandbox else API_URL_PROD,
    }


def split_domain(domain: str) -> tuple:
    parts = domain.rsplit(".", 1)
    if len(parts) != 2:
        print(f"ERROR: Cannot split domain '{domain}'.")
        sys.exit(1)
    return parts[0], parts[1]


# ──────────────────────────────────────────────
# API Calls
# ──────────────────────────────────────────────

def api_call(config: dict, command: str, extra_params: dict = None) -> ET.Element:
    params = {
        "ApiUser": config["api_user"],
        "ApiKey": config["api_key"],
        "UserName": config["api_user"],
        "ClientIp": config["client_ip"],
        "Command": command,
    }
    if extra_params:
        params.update(extra_params)

    url = f"{config['base_url']}?{urllib.parse.urlencode(params)}"

    try:
        req = urllib.request.Request(url)
        with urllib.request.urlopen(req, timeout=30) as response:
            xml_data = response.read().decode("utf-8")
    except Exception as e:
        print(f"ERROR: API call failed: {e}")
        sys.exit(1)

    root = ET.fromstring(xml_data)
    status = root.attrib.get("Status", "")
    if status == "ERROR":
        for elem in root.iter():
            if "Error" in elem.tag:
                print(f"ERROR [{elem.attrib.get('Number', '?')}]: {elem.text}")
        sys.exit(1)

    return root


def get_hosts(config: dict, domain: str) -> list:
    sld, tld = split_domain(domain)
    root = api_call(config, "namecheap.domains.dns.getHosts", {"SLD": sld, "TLD": tld})

    records = []
    for elem in root.iter():
        if "Host" in elem.tag and elem.attrib.get("Name"):
            records.append({
                "Name": elem.attrib.get("Name", ""),
                "Type": elem.attrib.get("Type", ""),
                "Address": elem.attrib.get("Address", ""),
                "MXPref": elem.attrib.get("MXPref", "10"),
                "TTL": elem.attrib.get("TTL", "1800"),
            })

    return records


def set_hosts(config: dict, domain: str, records: list) -> bool:
    sld, tld = split_domain(domain)
    params = {"SLD": sld, "TLD": tld}

    for i, rec in enumerate(records, 1):
        params[f"HostName{i}"] = rec["Name"]
        params[f"RecordType{i}"] = rec["Type"]
        params[f"Address{i}"] = rec["Address"]
        params[f"TTL{i}"] = rec.get("TTL", "1800")
        if rec["Type"] in ("MX", "MXE"):
            params[f"MXPref{i}"] = rec.get("MXPref", "10")

    api_call(config, "namecheap.domains.dns.setHosts", params)
    return True


# ──────────────────────────────────────────────
# Plan Operations
# ──────────────────────────────────────────────

def load_plan(plan_file: str) -> list:
    """Load and normalize a plan JSON to Namecheap host format."""
    with open(plan_file, "r", encoding="utf-8") as f:
        raw_plan = json.load(f)

    records = []
    for entry in raw_plan:
        rr = entry.get("rr", entry.get("Name", ""))
        record_type = entry.get("type", entry.get("Type", ""))
        value = entry.get("value", entry.get("Address", ""))
        ttl = str(entry.get("ttl", entry.get("TTL", 1800)))

        records.append({
            "Name": rr,
            "Type": record_type,
            "Address": value,
            "TTL": ttl,
            "MXPref": str(entry.get("MXPref", "10")),
            "_note": entry.get("note", ""),
            "_scope": entry.get("scope", "public"),
        })

    return records


def validate_plan(records: list, domain: str) -> list:
    """Validate plan entries and return warnings."""
    warnings = []
    seen = set()

    for i, rec in enumerate(records):
        name = rec.get("Name", "")
        rtype = rec.get("Type", "")
        value = rec.get("Address", "")
        scope = rec.get("_scope", "public")

        # Required fields
        if not name or not rtype or not value:
            warnings.append(f"❌ Entry {i}: missing Name, Type, or Address")
            continue

        # Placeholder check
        if "<" in value or ">" in value:
            warnings.append(f"❌ Entry {i} ({name}): value contains placeholder: {value}")

        # Unsupported type
        if rtype.upper() == "SRV":
            warnings.append(f"❌ Entry {i} ({name}): SRV records not supported by Namecheap API")

        if rtype not in SUPPORTED_TYPES:
            warnings.append(f"⚠️  Entry {i} ({name}): record type '{rtype}' may not be supported")

        # Duplicate
        key = (name, rtype)
        if key in seen and rtype not in ("MX", "TXT", "NS"):
            warnings.append(f"⚠️  Entry {i}: duplicate {name} {rtype}")
        seen.add(key)

        # Scope vs IP check
        if scope == "private" and rtype == "A" and not is_private_ip(value):
            warnings.append(f"🔒 Entry {i} ({name}): marked private but has public IP {value}")

    return warnings


def backup_records(records: list, domain: str) -> str:
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup_file = f"backup_{domain}_{timestamp}.json"
    with open(backup_file, "w", encoding="utf-8") as f:
        json.dump(records, f, indent=2, ensure_ascii=False)
    return backup_file


def apply_plan(config: dict, domain: str, plan_records: list, merge: bool = False, dry_run: bool = True):
    """Apply a DNS plan to Namecheap."""
    mode = "DRY RUN" if dry_run else "APPLY"
    strategy = "MERGE" if merge else "REPLACE"

    print(f"\n{'🔍' if dry_run else '🚀'} {mode} ({strategy}) for {domain}")
    print(f"   Records in plan: {len(plan_records)}\n")

    # Validate
    warnings = validate_plan(plan_records, domain)
    if warnings:
        print("⚠️  Validation warnings:")
        for w in warnings:
            print(f"   {w}")
        print()
        if any(w.startswith("❌") for w in warnings):
            print("❌ Fix errors before applying.")
            return

    # Fetch current records
    print("📡 Fetching current records...")
    current = get_hosts(config, domain)
    print(f"   Found {len(current)} existing records\n")

    # Build final record set
    if merge:
        # Merge: keep existing records not in plan, add/update from plan
        plan_keys = {(r["Name"], r["Type"]) for r in plan_records}
        # Keep records not covered by plan
        final = [r for r in current if (r["Name"], r["Type"]) not in plan_keys]
        # Add all plan records
        final.extend([{
            "Name": r["Name"],
            "Type": r["Type"],
            "Address": r["Address"],
            "TTL": r.get("TTL", "1800"),
            "MXPref": r.get("MXPref", "10"),
        } for r in plan_records])
        print(f"   📋 Merge result: {len(final)} total records")
        print(f"      Kept from existing: {len(final) - len(plan_records)}")
        print(f"      From plan: {len(plan_records)}")
    else:
        # Replace: plan becomes the entire record set
        final = [{
            "Name": r["Name"],
            "Type": r["Type"],
            "Address": r["Address"],
            "TTL": r.get("TTL", "1800"),
            "MXPref": r.get("MXPref", "10"),
        } for r in plan_records]
        print(f"   ⚠️  REPLACE mode: {len(current)} existing records will be replaced with {len(final)} plan records")

    # Show changes
    print(f"\n   Final record set:")
    for r in final:
        fqdn = f"{r['Name']}.{domain}" if r["Name"] != "@" else domain
        print(f"      {fqdn:<35} {r['Type']:<8} {r['Address']}")

    if dry_run:
        print(f"\n💡 Run with --apply to execute these changes.")
        return

    # Backup before write
    backup_file = backup_records(current, domain)
    print(f"\n   💾 Backup saved: {backup_file}")

    # Write
    print(f"   📤 Writing {len(final)} records...")
    set_hosts(config, domain, final)
    print(f"   ✅ Done! All records updated successfully.")
    print(f"   💡 To rollback, use: python namecheap_batch.py --domain {domain} --plan {backup_file} --apply")


def diff_plan(config: dict, domain: str, plan_file: str):
    """Show differences between plan and current state."""
    plan_records = load_plan(plan_file)
    current = get_hosts(config, domain)

    print(f"\n🔍 Diff: {plan_file} vs live records for {domain}\n")

    current_map = {}
    for r in current:
        key = (r["Name"], r["Type"])
        current_map[key] = r

    plan_map = {}
    for r in plan_records:
        key = (r["Name"], r["Type"])
        plan_map[key] = r

    # Records in plan
    for key, rec in sorted(plan_map.items()):
        fqdn = f"{key[0]}.{domain}" if key[0] != "@" else domain
        if key in current_map:
            curr = current_map[key]
            if curr["Address"] != rec["Address"]:
                print(f"   ✏️  CHANGE  {fqdn:<35} {key[1]:<6} {curr['Address']} -> {rec['Address']}")
            elif curr.get("TTL", "1800") != rec.get("TTL", "1800"):
                print(f"   ⏱️  TTL     {fqdn:<35} {key[1]:<6} TTL {curr['TTL']} -> {rec.get('TTL', '1800')}")
            else:
                print(f"   ✅ OK      {fqdn:<35} {key[1]:<6} {rec['Address']}")
        else:
            print(f"   ➕ NEW     {fqdn:<35} {key[1]:<6} {rec['Address']}")

    # Records only in current (would be deleted in REPLACE mode)
    for key, rec in sorted(current_map.items()):
        if key not in plan_map:
            fqdn = f"{key[0]}.{domain}" if key[0] != "@" else domain
            print(f"   🗑️  REMOVE  {fqdn:<35} {key[1]:<6} {rec['Address']}  (not in plan — will be deleted in REPLACE mode)")


def export_current(config: dict, domain: str, output_file: str):
    """Export current records to plan JSON format."""
    records = get_hosts(config, domain)
    plan = []
    for r in records:
        plan.append({
            "rr": r["Name"],
            "type": r["Type"],
            "value": r["Address"],
            "ttl": int(r.get("TTL", 1800)),
            "scope": "private" if (r["Type"] == "A" and is_private_ip(r["Address"])) else "public",
            "note": "",
        })

    with open(output_file, "w", encoding="utf-8") as f:
        json.dump(plan, f, indent=2, ensure_ascii=False)
    print(f"✅ Exported {len(plan)} records to {output_file}")


# ──────────────────────────────────────────────
# CLI
# ──────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="Namecheap DNS Batch Provisioner (SAFE: dry-run by default)",
        epilog="⚠️  REMEMBER: Namecheap setHosts OVERWRITES all records. Use --merge to keep existing records."
    )
    parser.add_argument("--domain", required=True, help="Domain name (e.g., gogenex.com)")
    parser.add_argument("--sandbox", action="store_true", help="Use sandbox API")

    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--plan", help="JSON plan file to apply")
    group.add_argument("--diff", help="Diff plan vs current state")
    group.add_argument("--export", help="Export current records to JSON")

    parser.add_argument("--apply", action="store_true", help="Actually apply changes (default is dry-run)")
    parser.add_argument("--merge", action="store_true",
                        help="Merge plan with existing records instead of replacing")

    args = parser.parse_args()
    config = get_config(sandbox=args.sandbox)

    if args.plan:
        plan_records = load_plan(args.plan)
        apply_plan(config, args.domain, plan_records, merge=args.merge, dry_run=not args.apply)
    elif args.diff:
        diff_plan(config, args.domain, args.diff)
    elif args.export:
        export_current(config, args.domain, args.export)


if __name__ == "__main__":
    main()
