#!/usr/bin/env python3
"""
Alibaba Cloud DNS Batch Provisioner
Reads a JSON plan file and creates/verifies all DNS records in batch.

Usage:
    python alidns_batch.py --domain gogenex.com --plan plan.json [--dry-run] [--force]
    python alidns_batch.py --domain gogenex.com --export current_records.json
    python alidns_batch.py --domain gogenex.com --diff plan.json

Plan JSON format (see references/plan-schema.md for full spec):
[
    {"rr": "api",    "type": "A",     "value": "1.2.3.4",         "ttl": 600, "scope": "public",  "note": "API gateway"},
    {"rr": "www",    "type": "CNAME", "value": "gogenex.com",     "ttl": 600, "scope": "public",  "note": "WWW redirect"},
    {"rr": "mpc",    "type": "A",     "value": "10.0.1.100",      "ttl": 600, "scope": "private", "note": "MPC signing service"},
    {"rr": "static", "type": "CNAME", "value": "cdn.example.com", "ttl": 300, "scope": "public",  "note": "CDN for static assets"}
]

Environment Variables:
    ALIBABA_CLOUD_ACCESS_KEY_ID     - RAM sub-user Access Key ID
    ALIBABA_CLOUD_ACCESS_KEY_SECRET - RAM sub-user Access Key Secret
"""

import argparse
import ipaddress
import json
import os
import sys
import time

try:
    from alibabacloud_alidns20150109.client import Client as AlidnsClient
    from alibabacloud_alidns20150109 import models as alidns_models
    from alibabacloud_tea_openapi import models as open_api_models
except ImportError:
    print("ERROR: Required packages not installed. Run:")
    print("  pip install alibabacloud_alidns20150109 alibabacloud_tea_openapi --break-system-packages")
    sys.exit(1)


# Private IP ranges (RFC 1918 + RFC 6598)
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"),
]


def is_private_ip(ip_str: str) -> bool:
    """Check if an IP address is in a private range."""
    try:
        ip = ipaddress.ip_address(ip_str)
        return any(ip in net for net in PRIVATE_RANGES)
    except ValueError:
        return False


def create_client(region_id: str = "cn-hangzhou") -> AlidnsClient:
    access_key_id = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_ID")
    access_key_secret = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_SECRET")

    if not access_key_id or not access_key_secret:
        print("ERROR: Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET env vars.")
        sys.exit(1)

    config = open_api_models.Config(
        access_key_id=access_key_id,
        access_key_secret=access_key_secret,
    )
    config.endpoint = f"alidns.{region_id}.aliyuncs.com"
    return AlidnsClient(config)


def get_existing_records(client: AlidnsClient, domain: str) -> list:
    """Fetch all existing DNS records for a domain."""
    all_records = []
    page = 1
    page_size = 100

    while True:
        request = alidns_models.DescribeDomainRecordsRequest(
            domain_name=domain,
            page_number=page,
            page_size=page_size,
        )
        response = client.describe_domain_records(request)
        records = response.body.domain_records.record
        all_records.extend(records)

        if len(all_records) >= response.body.total_count:
            break
        page += 1

    return all_records


def find_matching_record(existing: list, rr: str, record_type: str) -> object:
    """Find an existing record matching the given RR and type."""
    for r in existing:
        if r.rr == rr and r.type == record_type:
            return r
    return None


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

    for i, entry in enumerate(plan):
        # Required fields
        for field in ["rr", "type", "value"]:
            if field not in entry:
                warnings.append(f"❌ Entry {i}: missing required field '{field}'")

        # Duplicate check
        key = (entry.get("rr", ""), entry.get("type", ""))
        if key in seen:
            warnings.append(f"⚠️  Entry {i}: duplicate record {key[0]}.{domain} {key[1]}")
        seen.add(key)

        # Security: private scope pointing to public IP
        scope = entry.get("scope", "public")
        value = entry.get("value", "")
        record_type = entry.get("type", "")

        if scope == "private" and record_type == "A" and not is_private_ip(value):
            warnings.append(
                f"🔒 SECURITY: Entry {i} ({entry.get('rr')}.{domain}) is marked 'private' "
                f"but points to public IP {value}. Internal services should use private IPs."
            )

        if scope == "public" and record_type == "A" and is_private_ip(value):
            warnings.append(
                f"⚠️  Entry {i} ({entry.get('rr')}.{domain}) is marked 'public' "
                f"but points to private IP {value}. This won't be reachable from the internet."
            )

    return warnings


def apply_plan(client: AlidnsClient, domain: str, plan: list, dry_run: bool = False, force: bool = False):
    """Apply the DNS plan to Alibaba Cloud DNS."""
    print(f"\n{'🔍 DRY RUN' if dry_run else '🚀 APPLYING'} plan for {domain}")
    print(f"   Records in plan: {len(plan)}\n")

    # Validate
    warnings = validate_plan(plan, domain)
    if warnings:
        print("⚠️  Validation warnings:")
        for w in warnings:
            print(f"   {w}")
        print()

        has_errors = any(w.startswith("❌") for w in warnings)
        has_security = any(w.startswith("🔒") for w in warnings)

        if has_errors:
            print("❌ Plan has errors. Fix them before applying.")
            return

        if has_security and not force:
            print("🔒 Security warnings detected. Use --force to override.")
            return

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

    stats = {"added": 0, "updated": 0, "skipped": 0, "errors": 0}

    for entry in plan:
        rr = entry["rr"]
        record_type = entry["type"]
        value = entry["value"]
        ttl = entry.get("ttl", 600)
        note = entry.get("note", "")
        fqdn = f"{rr}.{domain}" if rr != "@" else domain

        match = find_matching_record(existing, rr, record_type)

        if match:
            if match.value == value and match.ttl == ttl:
                print(f"   ⏭️  SKIP   {fqdn:<35} {record_type:<6} {value}  (already correct)")
                stats["skipped"] += 1
            else:
                print(f"   ✏️  UPDATE {fqdn:<35} {record_type:<6} {match.value} -> {value}  {f'[{note}]' if note else ''}")
                if not dry_run:
                    try:
                        request = alidns_models.UpdateDomainRecordRequest(
                            record_id=match.record_id,
                            rr=rr,
                            type=record_type,
                            value=value,
                            ttl=ttl,
                        )
                        client.update_domain_record(request)
                        stats["updated"] += 1
                        time.sleep(0.1)  # Rate limit throttle
                    except Exception as e:
                        print(f"          ❌ Error: {e}")
                        stats["errors"] += 1
                else:
                    stats["updated"] += 1
        else:
            print(f"   ➕ ADD    {fqdn:<35} {record_type:<6} {value}  {f'[{note}]' if note else ''}")
            if not dry_run:
                try:
                    request = alidns_models.AddDomainRecordRequest(
                        domain_name=domain,
                        rr=rr,
                        type=record_type,
                        value=value,
                        ttl=ttl,
                    )
                    client.add_domain_record(request)
                    stats["added"] += 1
                    time.sleep(0.1)
                except Exception as e:
                    error_msg = str(e)
                    if "DomainRecordDuplicate" in error_msg:
                        print(f"          ⚠️  Already exists (different query match)")
                        stats["skipped"] += 1
                    else:
                        print(f"          ❌ Error: {e}")
                        stats["errors"] += 1
            else:
                stats["added"] += 1

    # Summary
    print(f"\n{'📊 DRY RUN SUMMARY' if dry_run else '📊 EXECUTION SUMMARY'}")
    print(f"   Added:   {stats['added']}")
    print(f"   Updated: {stats['updated']}")
    print(f"   Skipped: {stats['skipped']}")
    print(f"   Errors:  {stats['errors']}")

    if dry_run:
        print(f"\n💡 Run without --dry-run to apply these changes.")


def export_records(client: AlidnsClient, domain: str, output_file: str):
    """Export current DNS records to a JSON plan file."""
    records = get_existing_records(client, domain)

    plan = []
    for r in records:
        plan.append({
            "rr": r.rr,
            "type": r.type,
            "value": r.value,
            "ttl": r.ttl,
            "scope": "private" if (r.type == "A" and is_private_ip(r.value)) else "public",
            "note": "",
            "record_id": r.record_id,
            "status": r.status,
        })

    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}")


def diff_plan(client: AlidnsClient, domain: str, plan_file: str):
    """Show differences between current state and planned state."""
    with open(plan_file, "r", encoding="utf-8") as f:
        plan = json.load(f)

    existing = get_existing_records(client, domain)

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

    # Check plan entries against existing
    for entry in plan:
        rr = entry["rr"]
        record_type = entry["type"]
        value = entry["value"]
        ttl = entry.get("ttl", 600)
        fqdn = f"{rr}.{domain}" if rr != "@" else domain

        match = find_matching_record(existing, rr, record_type)

        if not match:
            print(f"   ➕ NEW     {fqdn:<35} {record_type:<6} {value}")
        elif match.value != value:
            print(f"   ✏️  CHANGE  {fqdn:<35} {record_type:<6} {match.value} -> {value}")
        elif match.ttl != ttl:
            print(f"   ⏱️  TTL     {fqdn:<35} {record_type:<6} TTL {match.ttl} -> {ttl}")
        else:
            print(f"   ✅ OK      {fqdn:<35} {record_type:<6} {value}")

    # Check for records not in plan
    plan_keys = {(e["rr"], e["type"]) for e in plan}
    for r in existing:
        if (r.rr, r.type) not in plan_keys:
            fqdn = f"{r.rr}.{domain}" if r.rr != "@" else domain
            print(f"   ⚠️  EXTRA   {fqdn:<35} {r.type:<6} {r.value}  (not in plan)")


def main():
    parser = argparse.ArgumentParser(description="Alibaba Cloud DNS Batch Provisioner")
    parser.add_argument("--domain", required=True, help="Domain name (e.g., gogenex.com)")
    parser.add_argument("--region", default="cn-hangzhou", help="Alidns region")

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

    parser.add_argument("--dry-run", action="store_true", help="Preview changes without applying")
    parser.add_argument("--force", action="store_true", help="Override security warnings")

    args = parser.parse_args()
    client = create_client(args.region)

    if args.plan:
        with open(args.plan, "r", encoding="utf-8") as f:
            plan = json.load(f)
        apply_plan(client, args.domain, plan, dry_run=args.dry_run, force=args.force)
    elif args.export:
        export_records(client, args.domain, args.export)
    elif args.diff:
        diff_plan(client, args.domain, args.diff)


if __name__ == "__main__":
    main()
