#!/usr/bin/env python3
"""
render-dc-overlays.py -- deterministic per-DC deploy-overlay renderer (D-136 option (D)).

D-136 was ADOPTED at option (D) 2026-07-27: the NetBox apex is the source, the
renderer emits per-DC deploy artifacts, and this deployment is NOT gated on it.

TWO STAGES, deliberately split -- this is the interface forward item F2 FROZE
("the renderer is a deterministic command-line tool whose inputs are the per-DC
values file and whose outputs are committed, reviewable artifacts. Nothing about
automating it should require changing the renderer."):

    derive : apex + lib-net.sh  ->  a per-DC VALUES file   (needs the repo; reads
             the apex record and shells out to lib-net; this is the half a CI job
             would re-run)
    render : VALUES file        ->  overlay TEXT on stdout (pure, offline,
             deterministic -- no apex, no lib-net, no network, no clock)

WHY A TEXT EMITTER AND NOT yaml.dump: the target artifacts are byte-compared
against frozen fixtures (tests/render-baseline/). A YAML round-tripper destroys
the comment header, normalises `vip: "a b c"` to a plain scalar, and imposes its
own key order -- all three are load-bearing bytes. PyYAML is used for READING the
values file, never for writing an overlay.

KEY ORDER IS NOT ALPHABETICAL. Applications are emitted in ascending VIP octet
(keystone .50 ... ceph-radosgw .60), which is the order the reviewed artifacts
already use. Sorting by name reproduces nothing.

THE COMMENT HEADER IS INPUT, NOT OUTPUT. It cites one-off measurements and a
design-doc fork; none of it is computable from the apex. `derive --from-overlay`
lifts it verbatim from the artifact being reproduced, so a reproduction run is
honest about which bytes the tool actually generates.

Exit: 0 ok | 1 mismatch/refused | 2 could-not-evaluate. ASCII + LF.
"""
import sys, os, json, glob, argparse, subprocess, ipaddress

try:
    import yaml
except ImportError:
    sys.stderr.write("ERROR: PyYAML not installed (pip install pyyaml --break-system-packages)\n")
    sys.exit(2)

REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
APEX_ENV, APEX_GLOB = "APEX_RECORD", "netbox/draft/vr1-office1-current-*.json"

# The ruled VIP octet band (D-020 amendment / R11). Kept in step with
# provider-bundle-check.py OCTET_LO/HI and dc-plane-apex-import.py BAND_VIP.
OCTET_LO, OCTET_HI = 50, 99

# The ruled app -> VIP octet map. Single source is netbox/dc-plane-apex-import.py:49-56
# (the tool that wrote these into the apex); duplicated here would be a second
# hand-maintained copy, so it is READ from that file rather than restated.
OCTET_SRC = os.path.join(REPO, "netbox", "dc-plane-apex-import.py")

# Which charms DECLARE a `prefer-ipv6` option. Same read-don't-restate rule: the
# measured authority is PREFER_IPV6_CHARMS in provider-bundle-check.py (the gate), and
# it carries the measurement, the revisions and the re-measure warning.
#
# NOTE ON KEYING, stated because it is a real (small) gap: that tuple is keyed by CHARM
# name, and a values file only carries APPLICATION names. Measured 2026-07-31: for all
# thirteen VIP applications in bundle.yaml the two are IDENTICAL, so matching on the app
# name is correct today. It is NOT guaranteed in general -- an application may be named
# anything. The charm-keyed assertion lives in provider-bundle-check invariant 9a, which
# reads the merged bundle and therefore SEES the charm; so if the two ever diverge the
# gate fails rather than the artifact silently going wrong. The renderer generates; the
# gate decides.
PREFER6_SRC = os.path.join(REPO, "scripts", "provider-bundle-check.py")

# The three VIP legs, in the column order every reviewed artifact already uses.
LEGS = ("provider-public", "metal-admin", "metal-internal")


def die(msg, rc=2):
    sys.stderr.write("ERROR: %s\n" % msg)
    sys.exit(rc)


def app_octets():
    """The ruled app->octet list, read from dc-plane-apex-import.py's APP_OCTET.

    Read rather than restated: a second copy is a second thing to drift. Parsed
    with ast so importing that module (which reaches for env credentials at
    import time is NOT the case, but which does carry a write path) is avoided.
    """
    import ast
    try:
        tree = ast.parse(open(OCTET_SRC, encoding="utf-8").read())
    except Exception as e:
        die("cannot read the ruled octet map from %s: %s" % (OCTET_SRC, e))
    for node in tree.body:
        if isinstance(node, ast.Assign) and any(
                getattr(t, "id", "") == "APP_OCTET" for t in node.targets):
            pairs = [(k.value, v.value) for k, v in
                     ((e.elts[0], e.elts[1]) for e in node.value.elts)]
            # REFUSE rather than emit. Measured (2026-07-29 chain audit): a duplicate app
            # name emitted TWO blocks for one app, `yaml.safe_load` silently kept the last,
            # and the gate passed -- one app's ruled octet vanished with nothing red. A
            # duplicate octet or an out-of-band one would be caught downstream by the gate,
            # but catching it HERE names the actual cause instead of a symptom.
            names = [n for n, _ in pairs]
            octs = [o for _, o in pairs]
            dupn = sorted({n for n in names if names.count(n) > 1})
            dupo = sorted({o for o in octs if octs.count(o) > 1})
            bad = sorted({o for o in octs if not (OCTET_LO <= int(o) <= OCTET_HI)})
            if dupn:
                die("APP_OCTET in %s has duplicate app name(s): %s" % (OCTET_SRC, ", ".join(dupn)))
            if dupo:
                die("APP_OCTET in %s has duplicate octet(s): %s" % (OCTET_SRC, ", ".join(map(str, dupo))))
            if bad:
                die("APP_OCTET in %s has octet(s) outside the ruled %d-%d band: %s"
                    % (OCTET_SRC, OCTET_LO, OCTET_HI, ", ".join(map(str, bad))))
            return pairs
    die("APP_OCTET not found in %s" % OCTET_SRC)


def prefer6_charms():
    """The charms that DECLARE prefer-ipv6, read from provider-bundle-check.py.

    Read rather than restated, for the same reason as APP_OCTET: a second copy is a
    second thing to drift, and this one is a PINNED MEASUREMENT that has to be redone
    when a channel moves.

    REFUSES rather than defaulting. An empty or missing tuple would silently render
    every app without the option -- which is a plausible-looking artifact and exactly
    the "could not look is not nothing there" failure this repo keeps hitting. An
    unreadable source is an error, not a licence to emit.
    """
    import ast
    try:
        tree = ast.parse(open(PREFER6_SRC, encoding="utf-8").read())
    except Exception as e:
        die("cannot read PREFER_IPV6_CHARMS from %s: %s" % (PREFER6_SRC, e))
    for node in tree.body:
        if isinstance(node, ast.Assign) and any(
                getattr(t, "id", "") == "PREFER_IPV6_CHARMS" for t in node.targets):
            try:
                names = [e.value for e in node.value.elts]
            except Exception as e:
                die("PREFER_IPV6_CHARMS in %s is not a flat tuple of strings: %s"
                    % (PREFER6_SRC, e))
            if not names or not all(isinstance(x, str) and x for x in names):
                die("PREFER_IPV6_CHARMS in %s is empty or holds a non-string entry"
                    % PREFER6_SRC)
            return frozenset(names)
    die("PREFER_IPV6_CHARMS not found in %s" % PREFER6_SRC)


def lib_net_planes(dc):
    """Plane name -> CIDR from lib-net.sh, the site-keyed v4 authority.

    Same shell-out as netbox/dc-plane-apex-import.py:96-105. lib_net_select_dc
    refuses a second selection in one shell, so this runs a fresh bash per DC.
    """
    sh = ('source "%s/scripts/lib-net.sh" >/dev/null 2>&1 || exit 3; '
          'lib_net_select_dc "%s" >/dev/null 2>&1 || exit 3; '
          'for c in "${PLANE_CIDRS[@]}"; do printf "%%s\\t%%s\\n" "${PLANE_NAME[$c]}" "$c"; done'
          % (REPO, dc))
    p = subprocess.run(["bash", "-c", sh], capture_output=True, text=True)
    if p.returncode != 0 or not p.stdout.strip():
        die("lib-net.sh did not yield planes for %s (rc=%d)" % (dc, p.returncode))
    return dict(l.split("\t") for l in p.stdout.strip().splitlines())


def v6_family(prefix):
    """'ula' for fc00::/7, else 'gua'. The ONE place family is decided."""
    return "ula" if ipaddress.ip_network(prefix).subnet_of(
        ipaddress.ip_network("fc00::/7")) else "gua"


def apex_v6(dc, family=None):
    """(role, kind) -> v6 /64 for `dc`, from the apex record.

    FAMILY (added 2026-08-01). D-139's ruled execution list has the GUA carve
    created ALONGSIDE the ULA rows at step 1 and the ULA rows retired only at
    step 6, so for the WHOLE migration window every v6-only plane has TWO /64s
    under one (role, kind) key. Keyed without a family dimension this function
    could only ever REFUSE -- measured live 2026-08-01, immediately after the
    step-1 push: "has TWO metal-admin/plane /64s ... refusing to pick one by
    array order", rc=2, which made `derive --dual-family` unrunnable for the
    entire transition it exists to serve.

    The refusal is KEPT, not weakened. What changes is that the caller can now
    SAY which family it wants; ambiguity with no `family` given still refuses,
    and two prefixes in the SAME family under one key still refuses -- that is
    the original 2026-07-29 defect and it is untouched.

    Scope matching is the THREE-WAY form (scope.slug / scope.name / scope_site).
    The live API returns scope.name as a DISPLAY name with scope.slug as the site
    key, while the repo dumps flatten to scope_site -- a tool written against one
    shape matches ZERO objects on the other, which has already happened once here.
    """
    rec = os.environ.get(APEX_ENV)
    if not rec:
        cand = sorted(glob.glob(os.path.join(REPO, APEX_GLOB)))
        rec = cand[-1] if cand else None
    if not rec or not os.path.isfile(rec):
        die("no readable apex record (looked for %s; set %s)" % (APEX_GLOB, APEX_ENV))
    try:
        doc = json.load(open(rec, encoding="utf-8"))
    except Exception as e:
        die("apex record %s unreadable: %s" % (rec, e))
    out = {}
    for p in (doc.get("ipam/prefixes") or []):
        pre = str(p.get("prefix", ""))
        if ":" not in pre or not pre.endswith("/64"):
            continue
        sc = p.get("scope") or {}
        if (sc.get("slug") or sc.get("name") or p.get("scope_site") or "") != dc:
            continue
        role = p.get("role") or ""
        role = role.get("slug") if isinstance(role, dict) else role
        key = (role, "vip" if "VIP" in str(p.get("description") or "") else "plane")
        fam = v6_family(pre)
        if family and fam != family:
            continue                      # caller asked for one family; this is the other
        # REFUSE on a duplicate key WITHIN a family. Measured (2026-07-29 chain audit):
        # this loop was last-writer-wins, so adding ONE spare prefix to the apex -- routine
        # IPAM work -- silently re-homed every dc0 VIP into a different /64 and the whole
        # chain went GREEN, because the gate only checks overlay-vs-apex AGREEMENT and both
        # read the same wrong value. Which prefix won depended on JSON array order, which is
        # not a contract. UNCHANGED by the 2026-08-01 family work.
        if key in out and out[key] != pre:
            if fam != v6_family(out[key]):
                # Cross-FAMILY ambiguity: legitimate mid-D-139, and the caller can resolve it
                # by saying which family it wants. Still never guessed.
                die("apex record %s has BOTH families for %s/%s in %s (%s and %s) -- this is "
                    "the expected D-139 transition state; re-run with --v6-family gua|ula. "
                    "Refusing to pick one by array order."
                    % (rec, key[0], key[1], dc, out[key], pre))
            die("apex record %s has TWO %s %s/%s /64s for %s (%s and %s) -- refusing to pick "
                "one by array order" % (rec, fam, key[0], key[1], dc, out[key], pre))
        out[key] = pre
    if not out:
        die("apex record %s carries no %s/64 prefixes scoped to %s"
            % (rec, (family + " ") if family else "", dc))
    return out, rec


def do_derive(a):
    planes = lib_net_planes(a.dc)
    missing = [l for l in LEGS if l not in planes]
    if missing:
        die("%s is missing plane(s) %s in lib-net -- refusing to guess a VIP leg"
            % (a.dc, ", ".join(missing)))
    v4 = {}
    for leg in LEGS:
        net = ipaddress.ip_network(planes[leg])
        v4[leg] = str(net.network_address).rsplit(".", 1)[0]   # e.g. 10.12.64

    vals = {"dc": a.dc, "output": a.output or "overlays/%s-vips.yaml" % a.dc,
            "family": "v4", "legs": list(LEGS), "prefixes_v4": v4,
            # Provenance, recorded on BOTH families. The v4 prefixes come from
            # lib-net.sh and NEVER from the apex, so a values file that carried
            # only `apex_record` would misattribute half its own inputs.
            "source_v4": "scripts/lib-net.sh lib_net_select_dc %s" % a.dc}

    if a.dual_family:
        v6map, rec = apex_v6(a.dc, a.v6_family)
        pre6 = {}
        for leg in LEGS:
            key = ("provider-public", "vip") if leg == "provider-public" else (leg, "plane")
            if key not in v6map:
                die("no apex v6 prefix for %s in %s -- refusing to invent one" % (key, a.dc))
            pre6[leg] = str(ipaddress.ip_network(v6map[key]).network_address).rstrip(":")
        vals["family"] = "dual"
        vals["prefixes_v6"] = pre6
        vals["apex_record"] = os.path.relpath(rec, REPO)
        # Provenance: WHICH family these came from. Without it a values file
        # derived mid-D-139 is indistinguishable from one derived before or
        # after the transition, and the overlay it renders is the deploy input.
        vals["v6_family"] = v6_family(list(v6map.values())[0])

    octets = app_octets()
    if not a.include_unbuilt:
        # vault .61 / designate .62 are RULED-BUT-NOT-BUILT (R11). They are in the
        # apex by design -- reserving a planned address is what an IPAM apex is for
        # -- but emitting them into a deploy artifact is a SEPARATE ruled step, so
        # they are excluded unless asked for. Reproducing today's 11-app artifact
        # depends on this default.
        known = {"vault", "designate"}
        octets = [(n, o) for n, o in octets if n not in known]
    vals["apps"] = [{"name": n, "octet": o} for n, o in octets]

    if a.from_overlay:
        hdr = []
        for line in open(a.from_overlay, encoding="utf-8").read().splitlines():
            if line.startswith("#"):
                hdr.append(line)
            else:
                break
        if not hdr:
            die("%s carries no leading comment header to lift" % a.from_overlay)
        vals["header"] = "\n".join(hdr) + "\n"

    txt = yaml.safe_dump(vals, sort_keys=False, default_flow_style=False, width=4096)
    (open(a.out, "w", encoding="utf-8") if a.out else sys.stdout).write(txt)
    return 0


def render(vals, prefer6=None):
    """VALUES -> overlay text. Pure: no I/O, no clock, no network.

    `prefer6` is the set of charms that DECLARE a prefer-ipv6 option. It is a
    PARAMETER rather than a lookup so this function keeps its purity property -- the
    reason the artifacts are byte-reproducible. Callers resolve it once with
    prefer6_charms(); the default is an explicit REFUSAL, never an empty set, because
    an empty set renders a plausible-looking artifact with the option nowhere.
    """
    if prefer6 is None:
        die("render() called without the prefer-ipv6 charm set -- resolve it with "
            "prefer6_charms() and pass it in; defaulting to empty would silently emit "
            "an artifact with the option nowhere")
    fam = vals.get("family", "v4")
    legs, pre4 = vals["legs"], vals["prefixes_v4"]
    pre6 = vals.get("prefixes_v6") or {}
    out = []
    if vals.get("header"):
        out.append(vals["header"] if vals["header"].endswith("\n") else vals["header"] + "\n")
    out.append("applications:\n")
    # ascending octet -- NOT alphabetical; this is the order the reviewed
    # artifacts use and sorting by name reproduces nothing.
    for app in sorted(vals["apps"], key=lambda x: int(x["octet"])):
        o = int(app["octet"])
        # D-020 amendment 2026-08-05: vault is METAL-ONLY -- metal-admin + metal-internal,
        # NO provider-public leg (the internal PKI/CA root must not be tenant-facing, and
        # its containers carry no provider leg to host the VIP -- a provider VIP findif-fails
        # live). And NO v6 legs even in a dual-family DC: vault stays the v4 metal PAIR
        # through a future D-141 rule-3 promotion until a new amendment rules on its v6 metal
        # legs. Named exception, mirroring provider-bundle-check.py and pre-flight-checks.sh
        # CHECK 1 -- fires for EVERY DC's values file (the ruled Roosevelt delta) rather than
        # relying on a per-DC flag that a new DC's file could silently omit.
        app_legs = [l for l in legs if l != "provider-public"] if app["name"] == "vault" else legs
        app_fam = "v4" if app["name"] == "vault" else fam
        addrs = ["%s.%d" % (pre4[l], o) for l in app_legs]
        if app_fam == "dual":
            # (vault never reaches here -- app_fam is forced v4 above.)
            # `::%d`, not `:%d`. The values file stores the /64 base with its
            # trailing colons stripped (2602:f3e2:f03:11), so the separator has to
            # put BOTH back -- a single colon yields 2602:f3e2:f03:11:50, which is
            # a malformed address that still LOOKS like one. This was the
            # renderer's first dual-family output and provider-bundle-check caught
            # it; the octet mirror is textual, so the digits stay decimal.
            addrs += ["%s::%d" % (pre6[l], o) for l in app_legs]
        out.append("  %s:\n    options:\n" % app["name"])
        if False and fam == "dual" and app["name"] in prefer6:  # RULED OFF -- see below
            # >>> RULED OFF ENTIRELY 2026-07-31 (D-101 RULING NOTE (b), GA-R5: "Set it
            # false on the seven, keep every v6 VIP leg"). The option is emitted for NO
            # application until IPv6 is OPERATIONAL. Measured cause: the LXD containers the
            # API charms run in hold only a link-local v6, so `get_relation_ip()` -- which
            # returns early with `get_ipv6_addr()[0]` when this is true -- raises and the
            # install hook dies. The condition is left in place, disabled, rather than
            # deleted: when the v6 completion work lands (allocatable ranges on the six v6
            # plane subnets, rack-side v6 legs, a v6 default route, v6-reachable services)
            # this is the one line that turns it back on, and PREFER_IPV6_CHARMS is still
            # the correct set to gate it by.
            #
            # Original 2026-07-31 (a) rationale, still true and still the reason the SIX
            # never get it: RULED (GA-R5: "Yes -- keep the v6 legs, remove only the
            # option"). This used to be emitted for EVERY dual-family
            # app, on the belief that prefer-ipv6 is what makes HAProxy bind :::port.
            # MEASURED FALSE: that bind is gated on `ipv6_enabled = not
            # is_ipv6_disabled()`, a read of the kernel disable_ipv6 sysctl, and
            # pacemaker picks IPv6addr by address-family detection. Six of the thirteen
            # VIP charms do not declare the option at all, and juju rejects the WHOLE
            # bundle on an unknown option -- which is how bundle deploy attempt 1 died
            # on 2026-07-31. The v6 legs stay; only the option is withheld.
            # Capture: docs/audit/stage5-prefer-ipv6-charm-research-20260731.txt
            out.append("      prefer-ipv6: true\n")
        # An optional trailing comment, carried so the ruling-3 extraction out of
        # bundle.yaml is LOSSLESS -- ten of those eleven comments are the bare
        # token `# B1`, whose definition stays in bundle.yaml's header block, and
        # three carry real operational content. An app with no comment emits none,
        # so the dc1 artifact (which has none) still reproduces byte-for-byte.
        cmt = (app.get("comment") or "").rstrip()
        out.append('      vip: "%s"%s\n' % (" ".join(addrs), (" " + cmt) if cmt else ""))
    return "".join(out)


def load_values(path):
    try:
        v = yaml.safe_load(open(path, encoding="utf-8"))
    except Exception as e:
        die("cannot parse values file %s: %s" % (path, e))
    for k in ("dc", "legs", "prefixes_v4", "apps"):
        if k not in (v or {}):
            die("values file %s lacks required key %r" % (path, k))
    if v.get("family") == "dual" and "prefixes_v6" not in v:
        die("values file %s declares family=dual but carries no prefixes_v6" % path)
    return v


def main():
    ap = argparse.ArgumentParser(description="Deterministic per-DC overlay renderer (D-136 (D))")
    sub = ap.add_subparsers(dest="cmd", required=True)

    d = sub.add_parser("derive", help="apex + lib-net -> a per-DC values file")
    d.add_argument("--dc", required=True, choices=["vr1-dc0", "vr1-dc1"])
    d.add_argument("--from-overlay", metavar="FILE",
                   help="lift the comment header verbatim from an existing overlay")
    d.add_argument("--v6-family", dest="v6_family", choices=("gua", "ula"),
                   help="which v6 family to take from the apex when it holds both "
                        "(the D-139 transition state). Omitted = refuse on ambiguity.")
    d.add_argument("--dual-family", action="store_true",
                   help="include the v6 legs (R2) -- needs the apex record")
    d.add_argument("--include-unbuilt", action="store_true",
                   help="also emit vault .61 / designate .62 (R11, ruled but not built)")
    d.add_argument("--output", help="the `output:` path recorded IN the values file")
    d.add_argument("-o", "--out", help="write the values file here (default stdout)")

    r = sub.add_parser("render", help="values file -> overlay text on stdout")
    r.add_argument("values")
    r.add_argument("-o", "--out", help="write here instead of stdout")

    c = sub.add_parser("check", help="render and byte-compare against an existing artifact")
    c.add_argument("values")
    c.add_argument("--against", required=True, metavar="FILE")

    a = ap.parse_args()
    if a.cmd == "derive":
        return do_derive(a)
    if a.cmd == "render":
        txt = render(load_values(a.values), prefer6_charms())
        (open(a.out, "w", encoding="utf-8") if a.out else sys.stdout).write(txt)
        return 0
    # check
    got = render(load_values(a.values), prefer6_charms())
    try:
        want = open(a.against, encoding="utf-8").read()
    except Exception as e:
        die("cannot read %s: %s" % (a.against, e))
    if got == want:
        print("  [ok]   byte-for-byte match: %s (%d bytes)" % (a.against, len(want)))
        print("PASS: rendered output reproduces %s exactly" % a.against)
        return 0
    import difflib
    print("  [FAIL] rendered output DIFFERS from %s" % a.against)
    gl, wl = got.splitlines(True), want.splitlines(True)
    for line in list(difflib.unified_diff(wl, gl, "committed", "rendered", n=1))[:40]:
        sys.stdout.write("        " + line if line.endswith("\n") else "        " + line + "\n")
    print("FAIL: %d rendered line(s) vs %d committed" % (len(gl), len(wl)))
    return 1


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