#!/usr/bin/env python3
"""
provider-bundle-check.py -- focused, fail-closed QA for the Pattern A provider revert (D-060).

Asserts ONLY the post-revert (D-052/D-053 + Pattern A) provider invariants on a
Charmed-OpenStack bundle:
  1. exactly 11 API charms bind public -> provider-public; none remain on provider-vip
  2. every clustered VIP is a triple: provider-public(10.12.4/22) admin(10.12.8/22)
     internal(10.12.12/22), all sharing one last octet in 50-60
  3. ovn-chassis bridge-interface-mappings carries ALL FOUR chassis MACs, INCLUDING
     openstack0's -- the Pattern A revert re-adds the openstack0 MAC that D-057 trimmed
     for the now-dead provider-vip plane.

Structural invariants (absorbed from the retired scripts/review-bundle.py -- DOCFIX-070;
that linter's expectations were pre-D-052 and its NOT CLEAN verdict was pure noise):
  4. every relation side names an EXISTING app and carries an explicit :endpoint
  5. mysql-innodb-cluster deploys at num_units 3 (D-062 -- single-unit seed never bootstraps)
  6. no VIP last-octet is shared between applications
  7. keystone ships the D-051/D-064 policy IN the bundle (resources: policyd-override,
     use-policyd-override true), and the committed zip content matches
     policies/domain-manager-policy.yaml (DOCFIX-071 drift guard; content compare, not
     byte compare, so zip mtimes cannot false-fail it)

It is the single deploy-gate: it REPLACED the retired scripts/d057-bundle-check.py (D-060)
and now also scripts/review-bundle.py (DOCFIX-070). FAIL -> exit 1. ASCII-only output.
(repo-lint: allow-stale-tokens -- guard checks name retired tokens by necessity)
"""
import sys, re, ipaddress, argparse
try:
    import yaml
except ImportError:
    sys.stderr.write("ERROR: PyYAML not installed (pip install pyyaml --break-system-packages)\n"); sys.exit(2)

# Per-DC VIP bands (D-101: only the network prefix moves per DC; the octet band is
# unchanged). --dc selects which set the VIP checks expect; default = the base
# bundle's dc0 values, so an un-parameterized run is byte-identical to before.
DC_BANDS = {
    "vr1-dc0": ("10.12.4.0/22", "10.12.8.0/22", "10.12.12.0/22"),
    "vr1-dc1": ("10.12.64.0/22", "10.12.68.0/22", "10.12.72.0/22"),
}
OCTET_LO, OCTET_HI = 50, 60
EXPECT_PUBLIC_VIP  = 11
EXPECT_CHASSIS_MACS = {
    "52:54:00:3d:fd:54",   # openstack0 -- re-added by the Pattern A revert (D-057 had trimmed it)
    "52:54:00:9d:63:77",   # openstack1
    "52:54:00:89:7f:ce",   # openstack2
    "52:54:00:99:fc:c2",   # openstack3
}
MAC_RE = re.compile(r"[0-9a-f]{2}(?::[0-9a-f]{2}){5}")
ROLES = ("control", "compute", "storage")

def _deep_merge(a, b):
    if isinstance(a, dict) and isinstance(b, dict):
        out = dict(a)
        for k, v in b.items():
            out[k] = _deep_merge(a[k], v) if k in a else v
        return out
    return b

def merge_overlay(base, over):
    """Juju-like overlay merge: applications/machines deep-merge key-by-key (a None
    overlay value deletes that key), relations append, other top-level keys override.
    NOTE: this MIRRORS juju's documented merge so the checker validates the effective
    deploy input; the machines-block merge in particular is CONFIRMED at
    `juju deploy --dry-run` before deploy (no offline juju merger exists)."""
    base, over = base or {}, over or {}
    out = dict(base)
    for section in ("applications", "machines"):
        b = dict(base.get(section) or {})
        for k, v in (over.get(section) or {}).items():
            if v is None:
                b.pop(k, None)
            elif isinstance(b.get(k), dict) and isinstance(v, dict):
                b[k] = _deep_merge(b[k], v)
            else:
                b[k] = v
        if b:
            out[section] = b
    rels = list(base.get("relations") or []) + list(over.get("relations") or [])
    if rels:
        out["relations"] = rels
    for k, v in over.items():
        if k not in ("applications", "machines", "relations"):
            out[k] = v
    return out

def _role_of(spec):
    """Extract control/compute/storage from a machine spec's `constraints: tags=...`."""
    m = re.search(r"tags=(\S+)", str((spec or {}).get("constraints", "")))
    tags = set(m.group(1).split(",")) if m else set()
    for r in ROLES:
        if r in tags:
            return r
    return None

def main():
    ap = argparse.ArgumentParser(description="Pattern A / D-052-D-053 + placement bundle invariants")
    ap.add_argument("path", nargs="?", default="bundle.yaml", help="base bundle (default bundle.yaml)")
    ap.add_argument("--overlay", action="append", default=[], metavar="FILE",
                    help="overlay bundle(s), applied in order (juju-like merge) to validate the effective deploy input")
    ap.add_argument("--dc", choices=sorted(DC_BANDS), default="vr1-dc0",
                    help="DC whose VIP bands to expect (default vr1-dc0 = the base bundle's own values)")
    args = ap.parse_args()
    path = args.path
    try:
        doc = yaml.safe_load(open(path, encoding="utf-8"))
    except Exception as e:
        sys.stderr.write("ERROR: cannot parse %s: %s\n" % (path, e)); return 2
    for ov in args.overlay:
        try:
            doc = merge_overlay(doc, yaml.safe_load(open(ov, encoding="utf-8")))
        except Exception as e:
            sys.stderr.write("ERROR: cannot merge overlay %s: %s\n" % (ov, e)); return 2
    PROVIDER, ADMIN, INTERNAL = (ipaddress.ip_network(x) for x in DC_BANDS[args.dc])
    apps = (doc or {}).get("applications", {}) or {}
    fails, oks = [], []

    def pub(s): return ((s or {}).get("bindings", {}) or {}).get("public")
    on_vip    = sorted(n for n, s in apps.items() if pub(s) == "provider-vip")
    on_public = sorted(n for n, s in apps.items() if pub(s) == "provider-public")
    if on_vip:
        fails.append("public still on provider-vip (must be reverted to provider-public): %s" % ", ".join(on_vip))
    if len(on_public) != EXPECT_PUBLIC_VIP:
        fails.append("public->provider-public count=%d (expect %d): %s" % (len(on_public), EXPECT_PUBLIC_VIP, ", ".join(on_public)))
    else:
        oks.append("%d charms bind public->provider-public; none on provider-vip" % len(on_public))

    vip_ok = 0
    for n, s in apps.items():
        vip = ((s or {}).get("options", {}) or {}).get("vip")
        if not vip:
            continue
        parts = str(vip).split()
        if len(parts) != 3:
            fails.append("%s vip not a triple: %r" % (n, vip)); continue
        prov, adm, intr = parts
        try:
            okp = ipaddress.ip_address(prov) in PROVIDER
            oka = ipaddress.ip_address(adm) in ADMIN
            oki = ipaddress.ip_address(intr) in INTERNAL
        except ValueError as e:
            fails.append("%s bad vip ip: %s" % (n, e)); continue
        if not okp: fails.append("%s provider leg %s not in %s" % (n, prov, PROVIDER)); continue
        if not oka: fails.append("%s admin leg %s not in %s" % (n, adm, ADMIN)); continue
        if not oki: fails.append("%s internal leg %s not in %s" % (n, intr, INTERNAL)); continue
        octs = {p.split(".")[-1] for p in parts}
        if len(octs) != 1:
            fails.append("%s vip octets differ: %r" % (n, vip)); continue
        o = int(octs.pop())
        if not (OCTET_LO <= o <= OCTET_HI):
            fails.append("%s vip octet .%d outside %d-%d" % (n, o, OCTET_LO, OCTET_HI)); continue
        vip_ok += 1
    if vip_ok:
        oks.append("%d clustered VIP(s) are provider-public/admin/internal triples, octet 50-60" % vip_ok)

    for n, s in apps.items():
        if (s or {}).get("charm") != "ovn-chassis":
            continue
        bim = str(((s or {}).get("options", {}) or {}).get("bridge-interface-mappings", ""))
        if not bim:
            continue
        macs = set(MAC_RE.findall(bim.lower()))
        missing = EXPECT_CHASSIS_MACS - macs
        if missing:
            fails.append("%s missing chassis MAC(s): %s" % (n, ", ".join(sorted(missing))))
        else:
            oks.append("%s bridge-interface-mappings: all 4 chassis MACs present (incl openstack0)" % n)

    # -- 4. relations: existing apps, explicit endpoints (the magnum-shared-db class) --
    rels = (doc or {}).get("relations", []) or []
    rel_bad = 0
    for r in rels:
        if not isinstance(r, list) or len(r) != 2:
            fails.append("relation not a 2-list: %r" % (r,)); rel_bad += 1; continue
        for side in r:
            side = str(side)
            if ":" not in side:
                fails.append("relation side lacks explicit :endpoint: %r" % side); rel_bad += 1; continue
            if side.split(":")[0] not in apps:
                fails.append("relation references unknown app: %r" % side); rel_bad += 1
    if rels and not rel_bad:
        oks.append("%d relations well-formed (explicit endpoints, all apps exist)" % len(rels))

    # -- 5. D-062: mysql-innodb-cluster at target count 3 --
    mi = apps.get("mysql-innodb-cluster") or {}
    if mi.get("num_units") != 3:
        fails.append("mysql-innodb-cluster num_units=%r (D-062 requires 3: single-unit seed never bootstraps)" % mi.get("num_units"))
    else:
        oks.append("mysql-innodb-cluster num_units=3 (D-062)")

    # -- 6. VIP octet uniqueness --
    seen_oct = {}
    for n, s in apps.items():
        vip = ((s or {}).get("options", {}) or {}).get("vip")
        if not vip: continue
        for part in str(vip).split():
            o = part.rsplit(".", 1)[-1]
            if o in seen_oct and seen_oct[o] != n:
                fails.append("VIP last octet .%s shared by %s and %s" % (o, seen_oct[o], n))
            seen_oct.setdefault(o, n)

    # -- 7. DOCFIX-071: keystone policy ships in-bundle, zip content matches source --
    import os, zipfile
    ks = apps.get("keystone") or {}
    res = ((ks.get("resources") or {}).get("policyd-override"))
    upo = ((ks.get("options") or {}).get("use-policyd-override"))
    if not upo:
        fails.append("keystone use-policyd-override is not true (D-051)")
    if not res:
        fails.append("keystone has no resources: policyd-override (DOCFIX-071: policy unreachable on redeploy)")
    else:
        base = os.path.dirname(os.path.abspath(path))
        zp = os.path.normpath(os.path.join(base, str(res)))
        src = os.path.join(base, "policies", "domain-manager-policy.yaml")
        if not os.path.isfile(zp):
            fails.append("policyd-override zip missing at %s" % zp)
        elif not os.path.isfile(src):
            fails.append("policy source missing at %s" % src)
        else:
            try:
                with zipfile.ZipFile(zp) as z:
                    inzip = z.read("domain-manager-policy.yaml")
                ondisk = open(src, "rb").read()
                if inzip != ondisk:
                    fails.append("policyd-override zip content DIFFERS from policies/domain-manager-policy.yaml (rebuild + recommit the zip)")
                else:
                    oks.append("keystone policyd-override wired in-bundle; zip content matches source (DOCFIX-071)")
            except KeyError:
                fails.append("zip lacks top-level domain-manager-policy.yaml (keystone reads the top-level name)")
            except Exception as e:
                fails.append("cannot read policyd zip: %s" % e)

    # -- 8. Placement / anti-affinity (role separation). Activates ONLY when the
    #       machines block carries control/compute/storage role tags; a base or
    #       un-rendered bundle has none, so the checks self-skip (base stays green).
    #       Run on the MERGED bundle (base + overlays) to see the scaled apps' real
    #       num_units/to:. Catches the decorative-HA paths juju itself won't error on.
    machines = (doc or {}).get("machines", {}) or {}
    roles = {str(mid): _role_of(spec) for mid, spec in machines.items()}
    if any(roles.values()):
        pf0 = len(fails)
        n_ctl = sum(1 for r in roles.values() if r == "control")
        n_cmp = sum(1 for r in roles.values() if r == "compute")
        n_stg = sum(1 for r in roles.values() if r == "storage")
        for n, s in apps.items():
            s = s or {}
            tos = [str(t) for t in (s.get("to") or [])]
            if not tos:
                continue
            ids = [t.split(":")[-1] for t in tos]
            for t, mid in zip(tos, ids):                       # a. no dangling placement
                if mid not in roles:
                    fails.append("%s to: references undefined machine %r" % (n, t))
            want = ("storage" if n == "ceph-osd" else "compute" if n == "nova-compute"
                    else "control" if any(t.startswith("lxd:") for t in tos) else None)
            if want:                                           # b. role placement
                off = [t for t, mid in zip(tos, ids) if roles.get(mid) != want]
                if off:
                    fails.append("%s placed off-role (want %s node): %s" % (n, want, ", ".join(off)))
            nu = s.get("num_units")                            # d. anti-affinity
            if isinstance(nu, int) and nu >= 2 and len(set(ids)) < nu:
                fails.append("%s num_units=%d but to: has %d distinct machine(s) -- anti-affinity defeat: %s"
                             % (n, nu, len(set(ids)), ids))
        for n, want, lbl in (("ceph-osd", n_stg, "storage"), ("nova-compute", n_cmp, "compute")):
            nu = (apps.get(n) or {}).get("num_units")          # c. bare-metal role counts
            if isinstance(nu, int) and nu != want:
                fails.append("%s num_units=%d != %d %s node(s)" % (n, nu, want, lbl))
        if len(fails) == pf0:
            oks.append("placement: role-separated (%d control/%d compute/%d storage); "
                       "anti-affinity + role placement + counts OK" % (n_ctl, n_cmp, n_stg))
    else:
        oks.append("placement: no role tags in machines block (base/un-rendered bundle) -- checks skipped")

    for o in oks:   print("  [ok]   %s" % o)
    for f in fails: print("  [FAIL] %s" % f)
    print("\n%s: Pattern A / D-052-D-053 bundle invariants (%s)" % ("PASS" if not fails else "FAIL", path))
    return 1 if fails else 0

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