"""CAIN-A75-031 offline verifier (rules embedded verbatim). CAIN-A75-031 context-graph rules. Standard library only, no CAIN imports: embedded verbatim in the offline verifier.

An agent queries a shared organisational context graph. The review recomputes whether the answer stayed inside tenancy,
ACL and depth:

  graph       node ids are unique, every edge references real nodes, and every relation is allowed by the policy
  tenancy     every returned node is in the same tenant as the querying principal
  acl         every returned node names the principal in its ACL, or is returned only in redacted form
  depth       no returned node is deeper than the query's maximum depth
  redaction   a node whose classification requires redaction is returned redacted unless the ACL grants full access
  claims      every boolean the record asserts matches what this review computed

Verdict: SOUND, else UNSOUND with every failing check named. Malformed input is UNSOUND.
It reviews the query result record; it does not query the graph store.
"""
from __future__ import annotations

import re

_ID = re.compile(r"^[A-Za-z0-9_.:\-]{1,80}$")
NODE_TYPES = {"person", "team", "project", "document", "system"}


class Bad(ValueError):
    pass


def evaluate(record):
    checks, summary = [], {"returned": {}, "max_returned_depth": 0}

    def c(cid, ok, detail=""):
        checks.append({"id": cid, "ok": bool(ok), "detail": detail})

    try:
        if not isinstance(record, dict) or record.get("schema") != "cain42.a75.context.graph.v1":
            raise Bad("not a cain42.a75.context.graph.v1 record")
        graph, query, pol = record.get("graph"), record.get("query"), record.get("policy")
        if not all(isinstance(x, dict) for x in (graph, query, pol)):
            raise Bad("graph, query and policy must be objects")
        nodes = graph.get("nodes")
        edges = graph.get("edges") if isinstance(graph.get("edges"), list) else []
        if not isinstance(nodes, list) or not nodes or len(nodes) > 5000:
            raise Bad("graph.nodes must be a non-empty list")
        N = {}
        for n in nodes:
            if not isinstance(n, dict) or not isinstance(n.get("id"), str) or not _ID.match(n["id"]):
                raise Bad("a node needs an id")
            if n["id"] in N:
                raise Bad(f"duplicate node {n['id']}")
            if n.get("type") not in NODE_TYPES:
                raise Bad(f"{n['id']}: type must be one of {sorted(NODE_TYPES)}")
            if not isinstance(n.get("tenant"), str) or not n["tenant"]:
                raise Bad(f"{n['id']}: tenant required")
            if not isinstance(n.get("acl"), list) or not all(isinstance(a, str) for a in n["acl"]):
                raise Bad(f"{n['id']}: acl must be a list of principals")
            N[n["id"]] = n
        allowed_rel = pol.get("allowed_relations")
        if not isinstance(allowed_rel, list) or not allowed_rel:
            raise Bad("policy.allowed_relations must be a non-empty list")
        for e in edges:
            if not isinstance(e, dict):
                raise Bad("an edge must be an object")
            c(f"edge.{e.get('from')}->{e.get('to')}.exists", e.get("from") in N and e.get("to") in N,
              f"{e.get('from')!r} -> {e.get('to')!r}")
            c(f"edge.{e.get('from')}->{e.get('to')}.allowed", e.get("relation") in allowed_rel,
              f"relation {e.get('relation')!r}")

        principal = query.get("principal")
        if not isinstance(principal, str) or not principal:
            raise Bad("query.principal required")
        tenant = query.get("tenant")
        if not isinstance(tenant, str) or not tenant:
            raise Bad("query.tenant required")
        max_depth = query.get("max_depth")
        if isinstance(max_depth, bool) or not isinstance(max_depth, int) or not (0 <= max_depth <= 20):
            raise Bad("query.max_depth must be an integer in [0, 20]")
        roots = query.get("roots")
        if not isinstance(roots, list) or not roots or not all(r in N for r in roots):
            raise Bad("query.roots must be existing nodes")
        c("policy.tenant_isolation", pol.get("tenant_isolation") is True, "tenant isolation is required")
        redact_for = pol.get("redaction_required_for") if isinstance(pol.get("redaction_required_for"), list) else []

        results = record.get("results")
        if not isinstance(results, list) or not results or len(results) > 5000:
            raise Bad("results must be a non-empty list")
        seen = set()
        for r in results:
            if not isinstance(r, dict) or not isinstance(r.get("node_id"), str):
                raise Bad("a result needs a node_id")
            nid = r["node_id"]
            if nid in seen:
                raise Bad(f"duplicate result {nid}")
            seen.add(nid)
            c(f"result.{nid}.exists", nid in N, "the node exists")
            if nid not in N:
                continue
            n = N[nid]
            c(f"result.{nid}.tenant", n["tenant"] == tenant, f"node tenant {n['tenant']!r}, query tenant {tenant!r}")
            depth = r.get("depth")
            if isinstance(depth, bool) or not isinstance(depth, int) or depth < 0:
                raise Bad(f"{nid}: depth must be a non-negative integer")
            c(f"result.{nid}.depth", depth <= max_depth, f"depth {depth} <= {max_depth}")
            summary["max_returned_depth"] = max(summary["max_returned_depth"], depth)
            acl = n["acl"]
            redacted = r.get("redacted") is True
            c(f"result.{nid}.acl", principal in acl or redacted or n.get("classification") == "public",
              "principal in ACL, or the node is redacted or public")
            needs_redaction = n.get("classification") in redact_for
            c(f"result.{nid}.redaction", not needs_redaction or redacted, "classified node is redacted")
            c(f"result.{nid}.type", n.get("type") in (query.get("requested_types") or [n.get("type")]),
              f"type {n.get('type')!r}")
            summary["returned"][nid] = {"depth": depth, "redacted": redacted}

        claims = record.get("claims") if isinstance(record.get("claims"), dict) else {}
        computed = {
            "tenant_isolated": all(N[r["node_id"]]["tenant"] == tenant for r in results if r.get("node_id") in N),
            "depth_bounded": all(isinstance(r.get("depth"), int) and not isinstance(r.get("depth"), bool) and r["depth"] <= max_depth
                                 for r in results),
            "acl_respected": all(principal in N[r["node_id"]]["acl"] or r.get("redacted") is True
                                 or N[r["node_id"]].get("classification") == "public"
                                 for r in results if r.get("node_id") in N),
        }
        for key in sorted(claims):
            c(f"claims.{key}", key in computed and claims[key] == computed[key], f"claimed {claims[key]!r}, computed {computed.get(key)!r}")
        summary["computed"] = computed
    except Bad as e:
        checks.append({"id": "input.well_formed", "ok": False, "detail": str(e)})
        return "UNSOUND", checks, summary
    except Exception as e:  # noqa: BLE001
        checks.append({"id": "input.error", "ok": False, "detail": f"{type(e).__name__}: {e}"[:200]})
        return "UNSOUND", checks, summary
    return ("SOUND" if all(x["ok"] for x in checks) else "UNSOUND"), checks, summary


# ---------------------------------------------------------------------------- offline verifier (no CAIN imports)
# Every name below is prefixed _kit_verifier_ / _b64 / _hl / _json / _sys so it cannot shadow a name in the rules above.
import base64 as _b64, hashlib as _hl, json as _json, sys as _sys
_KIT_VERIFIER_GENESIS = _hl.sha256(b"cain42.a75.contextgraph.genesis").hexdigest()


def _kit_verifier_canon(o):
    return _json.dumps(o, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()


def _kit_verifier_sha(o):
    return _hl.sha256(_kit_verifier_canon(o)).hexdigest()


def _kit_verifier_main(argv):
    from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
    if len(argv) < 2:
        print("usage: verify_offline.py run.json --pinned-key BASE64"); return 2
    run = _json.load(open(argv[1]))
    pin = argv[argv.index("--pinned-key") + 1] if "--pinned-key" in argv else None
    problems, prev, recs = [], _KIT_VERIFIER_GENESIS, run.get("records") or []
    for i, r in enumerate(recs):
        body = {k: v for k, v in r.items() if k not in ("record_hash", "signature_b64")}
        if _kit_verifier_sha(body) != r.get("record_hash"): problems.append(f"record {i}: record_hash does not match")
        if body.get("subject_sha256") != _kit_verifier_sha(body.get("subject")): problems.append(f"record {i}: subject_sha256 does not match")
        if pin and r.get("public_key_b64") != pin: problems.append(f"record {i}: different key than the pinned one")
        try:
            Ed25519PublicKey.from_public_bytes(_b64.b64decode(pin or r.get("public_key_b64"))).verify(
                _b64.b64decode(r.get("signature_b64", "")), str(r.get("record_hash")).encode())
        except Exception:
            problems.append(f"record {i}: signature does not verify")
        if r.get("prev_record_hash") != prev: problems.append(f"record {i}: chain link broken")
        v, checks, _ = evaluate(r.get("subject"))
        if v != r.get("verdict") or checks != r.get("checks"):
            problems.append(f"record {i}: re-running the rules gives {v}, the record says {r.get('verdict')}")
        prev = r.get("record_hash")
    if run.get("run_hash") != _kit_verifier_sha([r.get("record_hash") for r in recs]): problems.append("run_hash does not match")
    for p in problems: print("  -", p)
    if not pin: print("WARNING: no --pinned-key given; the signature was checked against the key inside the file")
    print("VERDICT:", "VERIFIED" if not problems else "TAMPERED OR INVALID", f"(CAIN-A75-031, {len(recs)} record(s), verdicts {run.get('verdicts')})")
    return 0 if not problems else 1


if __name__ == "__main__":
    _sys.exit(_kit_verifier_main(_sys.argv))
