#!/usr/bin/env python3
"""
D-139 execution STEP 6, one DC: re-home the v6 VIP ip-addresses from the retiring ULA
/64s onto their GUA counterparts, then DEPRECATE the ULA addresses and the ULA prefixes.

  . ~/vr1-office1-creds/vr1-netbox-sandbox.env    # the WORKING VR1 apex (DOCFIX-195)
  python3 netbox/d139-step6-vip-rehome.py --dc vr1-dc0            # DRY RUN (the default)
  python3 netbox/d139-step6-vip-rehome.py --dc vr1-dc0 --commit   # writes, then reads back

TWO OPERATOR RULINGS, both 2026-08-02 (GA-R5), quoted in docs/design-decisions.md:
  ordering  -- "Full step 6 first, then deploy"
  semantics -- "Deprecate both, delete nothing"

NOTHING IS EVER DELETED. "Retire" == status=deprecated. This preserves
d139-gua-carve.py's never-delete posture repo-wide -- that refusal is why the step-1 push
was safe to run.

REVERSIBILITY, STATED PRECISELY because an earlier draft of this docstring overclaimed it:
the 26+9 DEPRECATIONS are reversible by flipping a status back. The 26 CREATES are NOT --
undoing them would need a delete, and this repo has no delete path by design. The creates
are additive and are exactly the records the deploy configures, so reversal is not wanted;
but "every action is reversible" was wrong and is corrected here.

ORDER IS LOAD-BEARING AND IS NOT AN IMPLEMENTATION DETAIL: CREATE every GUA record,
VERIFY by read-back, and only THEN deprecate. Reversed, there is an interval in which the
apex marks a live VIP's only record unusable -- the same orphaning hazard DEFECT 3 of the
2026-08-01 CORRECTION NOTE identified for prefixes.

WHY IT DERIVES THE ULA->GUA MAP FROM THE ROLE SLUG rather than from hextet arithmetic:
the ULA and GUA hextets differ per DC (dc0's metal-admin is fd50:...:220 -> 2602:f3e2:f02:20)
and a transposition would be silent. Role slug is the stable key both sides share, and it
comes from d139-gua-carve.py's own CARVE table, which is IMPORTED rather than retyped.
If a role maps to more than one GUA /64 (provider-public owns :10 and :11) the tool
REFUSES rather than picking -- an ambiguous mapping is an unrecognised state.

MEASURED, not assumed (2026-08-02, live apex): the existing ULA VIP ip-addresses carry
status="reserved" -- NOT "active" -- and role/dns_name/tenant/vrf/tags/custom_fields are all
empty. dc-plane-apex-import.py:186,200 also creates addresses as "reserved". So the GUA
records are created RESERVED, mirroring the originals, and only address + status +
description carry meaning.

Exit: 0 ok | 1 write/read-back error | 2 REFUSE (could not evaluate).
"""
import argparse
import importlib.util
import ipaddress
import os
import sys
import urllib.parse

_HERE = os.path.dirname(os.path.abspath(__file__))


def _load_carve():
    """Import d139-gua-carve.py by path -- its hyphenated name is not importable normally.
    Reusing its NB client and CARVE table is deliberate: a second hand-copied table is
    exactly the transposition this repo keeps finding."""
    p = os.path.join(_HERE, "d139-gua-carve.py")
    spec = importlib.util.spec_from_file_location("d139_gua_carve", p)
    if spec is None or spec.loader is None:
        print("REFUSE: cannot load %s" % p, file=sys.stderr)
        sys.exit(2)
    m = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(m)
    return m


C = _load_carve()
die = C.die

# The status the GUA VIP records are CREATED with. MEASURED from the ULA originals
# (2026-08-02): they are "reserved", not "active". See module docstring.
VIP_STATUS = "reserved"
DEPRECATED = "deprecated"


class NBW(C.NB):
    """The carve tool's client plus PATCH -- it never needed one, having no update path."""

    def patch(self, path, body):
        return self._req("PATCH", path, body)


def gua_net_for_role(dc, role, live_gua=None):
    """The GUA /64 carrying `role` for this DC, from the imported CARVE table.

    REFUSES on an ambiguous role rather than choosing, and -- when `live_gua` is
    supplied -- REFUSES unless the computed /64 ACTUALLY EXISTS in the apex.

    DEF-1 (adversarial review 2026-08-02, CRITICAL): computing the target
    arithmetically and never asking the apex whether it exists is a silent
    orphan-create. MEASURED: vr1-dc1's GUA carve is INCOMPLETE -- only four rows exist
    under 2602:f3e2:f03::/48, all provider-public, with no :20::/64 and no :21::/64 --
    so `--dc vr1-dc1` planned 26 creates into prefixes that DO NOT EXIST and then
    deprecated dc1's only authoritative rows, at rc=0 with no warning. dc0 hid the bug
    because all sixteen of its targets happen to exist. The imported carve tool checks
    its own preconditions ("refusing to half-carve a DC"); this one checked none.
    """
    base48 = ipaddress.ip_network(C.DC_GUA[dc])
    hextets = [nn for nn, r, _lbl, _parent in C.CARVE if r == role]
    if not hextets:
        die("no D-139 GUA plane carries apex role %r -- cannot re-home its addresses" % role)
    if len(hextets) > 1:
        die("apex role %r maps to %d GUA /64s (%s) -- ambiguous, refusing to choose"
            % (role, len(hextets), ", ".join("%#x" % h for h in hextets)))
    net = C.sub_at(base48, hextets[0], 64)
    if live_gua is not None and str(net) not in live_gua:
        die("target GUA prefix %s (role %r) DOES NOT EXIST in the apex for %s. Step 1/2 "
            "have not carved this DC, so creating addresses inside it would orphan them. "
            "Run netbox/d139-gua-carve.py --dc %s --commit first." % (net, role, dc, dc))
    return net


def plan(dc, prefixes, addrs):
    """Returns (creates, dep_addrs, dep_prefixes, already).

    creates      -- GUA ip-address payloads to POST
    dep_addrs    -- ULA ip-address rows to mark deprecated
    dep_prefixes -- ULA prefix rows to mark deprecated
    already      -- GUA addresses that already exist (idempotent re-run)
    """
    ula48 = ipaddress.ip_network(C.RETIRED_ULA_48)
    gua48 = ipaddress.ip_network(C.DC_GUA[dc])

    # DEF-1: the set of GUA prefixes that ACTUALLY EXIST for this DC. gua_net_for_role()
    # refuses against this, so a target is never computed into thin air.
    live_gua = {str(ipaddress.ip_network(p["prefix"])) for p in prefixes
                if C.scope_slug(p) == dc}

    # The ULA prefixes in scope: this DC's, inside the retired /48. Same selection the
    # carve tool's RETIRE-REPORT makes, so the two cannot disagree about scope.
    ula_pfx = []
    for p in prefixes:
        if C.scope_slug(p) != dc:
            continue
        n = ipaddress.ip_network(p["prefix"])
        if n.version == 6 and n.subnet_of(ula48):
            ula_pfx.append((n, p))
    if not ula_pfx:
        die("no ULA prefixes in scope for %s -- nothing to do, and that is not a state this "
            "tool should report as success (step 1/2 may target a different apex)" % dc)

    existing = {str(ipaddress.ip_interface(a["address"]).ip) for a in addrs}

    creates, dep_addrs, already = [], [], []
    for n, p in ula_pfx:
        if n.prefixlen != 64:
            continue                      # /60 parents hold no addresses; deprecated below
        role = (p.get("role") or {}).get("slug")
        if not role:
            die("ULA prefix %s has no apex role -- cannot map it to a GUA plane" % n)
        gua = gua_net_for_role(dc, role, live_gua)
        for a in addrs:
            ai = ipaddress.ip_interface(a["address"])
            if ai.version != 6 or ai.ip not in n:
                continue
            offset = int(ai.ip) - int(n.network_address)
            tgt = ipaddress.ip_address(int(gua.network_address) + offset)
            dep_addrs.append(a)
            if str(tgt) in existing:
                already.append((str(tgt), a.get("description", "")))
            else:
                creates.append({
                    "address": "%s/%d" % (tgt, gua.prefixlen),
                    "status": VIP_STATUS,
                    # Description kept VERBATIM: it names the service and the plane, both
                    # still true on GUA, and the 2026-08-01 conformance audit identified
                    # these objects BY description. Inventing new text would break that.
                    "description": a.get("description", ""),
                })

    # DEF-3 (adversarial review 2026-08-02, HIGH): SILENT UNDER-COUNT. Deleting or
    # re-scoping ONE ULA /64 prefix row yielded CREATE=13 / DEPA=13 / DEPP=8 at exit 0 --
    # thirteen live VIPs neither created nor deprecated, reported as success. Same class
    # as DEFECT 2 of the 2026-08-01 CORRECTION NOTE (a carve that does less and exits
    # clean). The prefix ROW is not the authority on what exists; the ADDRESSES are.
    # So: every v6 address of this DC inside the retired /48 must be covered by an
    # in-scope /64. Any that is not is an unrecognised state, and unrecognised REFUSES
    # rather than being quietly skipped.
    # Addresses belonging to ANOTHER DC are legitimately out of scope -- the retired /48
    # is shared (dc0 uses :22x, dc1 uses :32x). Scoping this check to the whole /48 was a
    # FALSE POSITIVE caught by running it: it flagged dc1's 26 VIPs while planning dc0.
    # So an address is accounted for if EITHER this DC processed it, OR some other DC's
    # ULA prefix row covers it. What remains -- inside the retired /48, in none of this
    # DC's processed /64s, and claimed by no other DC -- is the real under-count signal.
    # Note a /60 PARENT does not count as coverage: the reviewer's scenario was exactly a
    # missing /64 row whose /60 parent still existed, and treating the parent as coverage
    # would re-open the hole this guard closes.
    other_dc_ula = []
    for p in prefixes:
        if C.scope_slug(p) == dc:
            continue
        try:
            n = ipaddress.ip_network(p["prefix"])
        except ValueError:
            continue
        if n.version == 6 and n.subnet_of(ula48):
            other_dc_ula.append(n)

    covered = {id(a) for a in dep_addrs}
    orphans = []
    for a in addrs:
        try:
            ai = ipaddress.ip_interface(a["address"])
        except ValueError:
            continue
        if ai.version != 6 or ai.ip not in ula48:
            continue
        if id(a) in covered:
            continue
        if any(ai.ip in n for n in other_dc_ula):
            continue                      # another DC's row owns it -- not our business
        orphans.append(a)
    if orphans:
        die("%d ULA ip-address(es) inside %s are claimed by NO prefix row -- not by an "
            "in-scope /64 for %s, and not by any other DC. Their /64 row is missing, "
            "mis-scoped or roleless, and processing the rest would silently do less than "
            "step 6 requires. First: %s"
            % (len(orphans), ula48,
               dc, ", ".join(a["address"] for a in orphans[:4])))

    dep_prefixes = [p for _n, p in ula_pfx
                    if (p.get("status") or {}).get("value") != DEPRECATED]
    dep_addrs = [a for a in dep_addrs
                 if (a.get("status") or {}).get("value") != DEPRECATED]
    return creates, dep_addrs, dep_prefixes, already


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--dc", required=True, choices=sorted(C.DC_GUA))
    ap.add_argument("--commit", action="store_true",
                    help="apply; without it this is a DRY RUN and writes nothing")
    a = ap.parse_args()

    url, tok = os.environ.get("NETBOX_URL"), os.environ.get("NETBOX_TOKEN")
    if not url or not tok:
        die("set NETBOX_URL and NETBOX_TOKEN from the env (never argv -- it would land in "
            "shell history): . ~/vr1-office1-creds/vr1-netbox-sandbox.env")

    # DEF-2 (adversarial review 2026-08-02, CRITICAL): the apex-IDENTITY guard. It exists
    # in d139-gua-carve.py's main() (:159-163), and importing that module runs its
    # constants and classes but NEVER its main() -- so subclassing C.NB inherited the
    # TRANSPORT and left the SAFETY POSTURE behind. Identity is checked BEFORE any network
    # call, because reachability is not identity: pointing this at the v1 reference would
    # connect fine and write to the wrong NetBox.
    host = urllib.parse.urlsplit(url).hostname or ""
    if host not in C.SANDBOX_HOSTS:
        die("'%s' is not the VR1 working apex. office1-netbox (10.10.1.10) takes ALL VR1 "
            "reads and writes; netbox.baldurkeep.com is the v1 REFERENCE and stays "
            "untouched (DOCFIX-195)." % host)

    nb = NBW(url, tok)
    try:
        prefixes = nb.get_all("/ipam/prefixes/")
        addrs = nb.get_all("/ipam/ip-addresses/")
    except RuntimeError as e:
        die("apex unreachable or rejecting: %s" % e)

    creates, dep_addrs, dep_prefixes, already = plan(a.dc, prefixes, addrs)

    print("D-139 STEP 6 -- %s -- apex %s" % (a.dc, url))
    print("   CREATE %d | ALREADY %d | DEPRECATE-ADDR %d | DEPRECATE-PFX %d"
          % (len(creates), len(already), len(dep_addrs), len(dep_prefixes)))

    print("\n  CREATE (GUA, status=%s):" % VIP_STATUS)
    for c in creates:
        print("    %-34s %s" % (c["address"], c["description"]))
    if already:
        print("\n  ALREADY PRESENT (idempotent -- left untouched):")
        for addr, desc in already:
            print("    %-34s %s" % (addr, desc))
    print("\n  DEPRECATE ip-addresses (ULA -- NOT deleted):")
    for x in dep_addrs:
        print("    %-34s %s" % (x["address"], x.get("description", "")))
    print("\n  DEPRECATE prefixes (ULA -- NOT deleted):")
    for x in dep_prefixes:
        print("    %-34s role=%s" % (x["prefix"], (x.get("role") or {}).get("slug")))

    if not a.commit:
        print("\nDRY RUN -- nothing was written. Re-run with --commit to apply.")
        return 0

    # ---- PHASE 1: CREATE. Nothing is deprecated until every create is read back. ----
    try:
        for c in creates:
            nb.post("/ipam/ip-addresses/", c)
    except RuntimeError as e:
        print("FAIL: create: %s" % e, file=sys.stderr)
        print("NOTHING WAS DEPRECATED -- the ULA records are untouched and still "
              "authoritative. Re-run; creates are idempotent.", file=sys.stderr)
        return 1

    # ---- PHASE 2: VERIFY BY READ-BACK, against the artifact and not the response. ----
    try:
        back = {str(ipaddress.ip_interface(x["address"]).ip)
                for x in nb.get_all("/ipam/ip-addresses/")}
    except RuntimeError as e:
        die("read-back failed: %s" % e)
    missing = [c["address"] for c in creates
               if str(ipaddress.ip_interface(c["address"]).ip) not in back]
    if missing:
        print("FAIL: %d GUA address(es) not present on read-back: %s"
              % (len(missing), ", ".join(missing)), file=sys.stderr)
        print("NOTHING WAS DEPRECATED -- the ULA records remain authoritative.",
              file=sys.stderr)
        return 1
    print("\n  read-back OK: %d GUA VIP address(es) present" % len(creates))

    # ---- PHASE 3: DEPRECATE. Only now, and never a delete. ----
    try:
        for x in dep_addrs:
            nb.patch("/ipam/ip-addresses/%d/" % x["id"], {"status": DEPRECATED})
        for x in dep_prefixes:
            nb.patch("/ipam/prefixes/%d/" % x["id"], {"status": DEPRECATED})
    except RuntimeError as e:
        print("FAIL: deprecate: %s" % e, file=sys.stderr)
        print("The GUA records ARE created and verified; some ULA rows may still read "
              "active. Re-run -- deprecation is idempotent.", file=sys.stderr)
        return 1

    # ---- PHASE 4: verify the deprecation landed, again on the artifact. ----
    try:
        pfx2 = {p["id"]: p for p in nb.get_all("/ipam/prefixes/")}
        adr2 = {x["id"]: x for x in nb.get_all("/ipam/ip-addresses/")}
    except RuntimeError as e:
        die("post-deprecate read-back failed: %s" % e)
    bad = [x["address"] for x in dep_addrs
           if (adr2.get(x["id"], {}).get("status") or {}).get("value") != DEPRECATED]
    bad += [p["prefix"] for p in dep_prefixes
            if (pfx2.get(p["id"], {}).get("status") or {}).get("value") != DEPRECATED]
    if bad:
        print("FAIL: %d row(s) did not take status=deprecated: %s"
              % (len(bad), ", ".join(bad)), file=sys.stderr)
        return 1

    print("  read-back OK: %d address(es) + %d prefix(es) now deprecated"
          % (len(dep_addrs), len(dep_prefixes)))
    print("\nSTEP 6 APPLIED. Nothing was deleted; every change is reversible by "
          "flipping a status back.")
    return 0


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