#!/usr/bin/env python3
"""
NetBox IPv6 entries -- mark as Reservation status (v1/v2 fork support).

Per D-015 (v1/v2 fork) and Q3 from the v1/v2 fork session: existing IPv6
prefixes scoped to VR0 DC0 are NOT decommissioned. They remain in NetBox to
document v2 design intent, but their status is set to "reserved" rather
than "active" so it is clear they are not in use during the v1 deployment.

The expected behavior:
  - Find all IPv6 prefixes scoped to the VR0 DC0 site.
  - For each, set status = "reserved" (NetBox's standard reservation state).
  - Optionally append a description suffix noting v2-scope.
  - Print a verification block.

Idempotent: prefixes already in "reserved" status are detected and reported
without modification. Pass --revert to set them back to "active" (use only
when v2 work begins).

NetBox version: 4.x.

Usage:
    NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN=<token> \\
        python3 ipv6-mark-reserved.py

    # Preview without changes:
    NETBOX_URL=... NETBOX_TOKEN=... python3 ipv6-mark-reserved.py --dry-run

    # Revert to active (only when v2 work begins):
    NETBOX_URL=... NETBOX_TOKEN=... python3 ipv6-mark-reserved.py --revert

WARNING: This script touches only IPv6 prefixes (those with ':' in the CIDR)
scoped to the VR0 DC0 site. IPv4 prefixes are NEVER modified.
"""

from __future__ import annotations

import argparse
import os
import sys

try:
    import pynetbox
except ImportError:
    sys.stderr.write("ERROR: pynetbox not installed. pip install pynetbox\n")
    sys.exit(1)

SITE_SLUG = "vr0-dc0"
SITE_NAME = "VR0 DC0"

# NetBox status value for reservation. NetBox 4.x exposes these as choices
# on the prefix.status field. The canonical lowercase slug is "reserved".
STATUS_RESERVED = "reserved"
STATUS_ACTIVE = "active"

# Description suffix appended when marking as reserved. Idempotent: only
# appended if not already present.
V2_SUFFIX = " [v2-scope; reserved per D-015]"


def die(msg: str, code: int = 1) -> None:
    sys.stderr.write(f"ERROR: {msg}\n")
    sys.exit(code)


def get_nb():
    url = os.environ.get("NETBOX_URL")
    token = os.environ.get("NETBOX_TOKEN")
    if not url:
        die("NETBOX_URL environment variable not set")
    if not token:
        die("NETBOX_TOKEN environment variable not set")
    nb = pynetbox.api(url, token=token)
    try:
        _ = nb.status()
    except Exception as exc:
        die(f"Could not reach NetBox at {url}: {exc}")
    return nb


def find_site(nb, slug: str):
    site = nb.dcim.sites.get(slug=slug)
    if site is None:
        die(f"Site with slug '{slug}' not found in NetBox")
    return site


def is_ipv6(prefix_str: str) -> bool:
    """Cheap and safe check -- IPv6 prefixes contain ':' in the CIDR."""
    return ":" in prefix_str


def get_status_value(p) -> str:
    """
    Extract a comparable lowercase status slug from a pynetbox prefix.
    pynetbox's status field can be a string or a Choices object depending
    on the API and library version. We normalize to lowercase string.
    """
    s = getattr(p, "status", None)
    if s is None:
        return ""
    # pynetbox Choices object has .value; string status is just the string
    val = getattr(s, "value", None)
    if val is None:
        val = str(s)
    return str(val).lower()


def find_ipv6_prefixes_at_site(nb, site):
    """Return list of IPv6 prefixes scoped to the given site."""
    all_prefixes = list(nb.ipam.prefixes.filter(scope_id=site.id, scope_type="dcim.site"))
    return [p for p in all_prefixes if is_ipv6(p.prefix)]


def update_status(nb, p, target_status: str, dry_run: bool) -> str:
    """Update prefix status, optionally appending/removing the v2 suffix.

    Returns one of: "CREATED-RESERVED", "ALREADY-RESERVED", "REVERTED-ACTIVE",
    "ALREADY-ACTIVE", "DRY-RUN-WOULD-CHANGE", "DRY-RUN-NO-CHANGE".
    """
    current = get_status_value(p)
    cur_desc = p.description or ""

    if target_status == STATUS_RESERVED:
        if current == STATUS_RESERVED:
            return "ALREADY-RESERVED"
        new_desc = cur_desc if V2_SUFFIX in cur_desc else (cur_desc + V2_SUFFIX)
        payload = {"status": STATUS_RESERVED, "description": new_desc.strip()}
    elif target_status == STATUS_ACTIVE:
        if current == STATUS_ACTIVE:
            return "ALREADY-ACTIVE"
        new_desc = cur_desc.replace(V2_SUFFIX, "").strip()
        payload = {"status": STATUS_ACTIVE, "description": new_desc}
    else:
        die(f"Unknown target_status: {target_status}")

    if dry_run:
        return "DRY-RUN-WOULD-CHANGE"

    p.update(payload)
    return "CREATED-RESERVED" if target_status == STATUS_RESERVED else "REVERTED-ACTIVE"


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
    parser.add_argument(
        "--revert",
        action="store_true",
        help="Set IPv6 prefixes back to 'active' (use only when v2 work begins)",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Preview changes without modifying NetBox",
    )
    args = parser.parse_args()

    target_status = STATUS_ACTIVE if args.revert else STATUS_RESERVED

    nb = get_nb()
    site = find_site(nb, SITE_SLUG)
    print(f"Connected. Site '{SITE_NAME}' (id={site.id}).")
    print(f"Target status: {target_status}")
    print(f"Dry-run: {args.dry_run}")

    ipv6_prefixes = find_ipv6_prefixes_at_site(nb, site)
    if not ipv6_prefixes:
        print(f"\nNo IPv6 prefixes found scoped to site '{SITE_NAME}'.")
        print("Nothing to do.")
        return 0

    print(f"\nFound {len(ipv6_prefixes)} IPv6 prefix(es) at this site:")
    for p in ipv6_prefixes:
        print(f"  - {p.prefix.ljust(32)} status={get_status_value(p).ljust(12)} (id={p.id})")

    print("\nProcessing:")
    for p in ipv6_prefixes:
        result = update_status(nb, p, target_status, args.dry_run)
        print(f"  {p.prefix.ljust(32)} {result}")

    # Verification block
    print()
    print("=" * 72)
    print(f"Verification -- IPv6 prefixes at site {SITE_NAME}")
    print("=" * 72)
    final = find_ipv6_prefixes_at_site(nb, site)
    for p in final:
        status_str = get_status_value(p)
        print(f"  {p.prefix.ljust(32)} status={status_str.ljust(12)} (id={p.id})")
    print(f"\nTotal: {len(final)} IPv6 prefix(es).")

    return 0


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