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

An agent compacts retained reasoning so it can keep working in a smaller context. Compaction is lossy by design; the
question is whether it lost something it promised to keep. The rules check the record:

  binding     the source context hash covers exactly the listed items; every item's hash covers its text
  compression the summary is smaller than the source and within the declared ratio
  retention   every must-retain item appears in the summary's retained list with the SAME content hash as the source
              (verbatim retention, not "the model says it kept it")
  no loss     no must-retain item is in the dropped list
  no invention the summary does not claim to retain an item that was not in the source
  claims      every boolean the record asserts matches what this review computed

Verdict: PRESERVED, else LOST with every failing check named. Malformed input is LOST.
It reviews the compaction record. It does not judge whether the summary is a good summary.
"""
from __future__ import annotations

import hashlib
import json
import math
import re

_ID = re.compile(r"^[A-Za-z0-9_.:\-]{1,80}$")
_HEX = re.compile(r"^[0-9a-f]{64}$")
KINDS = {"constraint", "decision", "fact", "open_question"}


class Bad(ValueError):
    pass


def text_hash(text):
    return hashlib.sha256(" ".join(text.split()).encode("utf-8")).hexdigest()


def context_hash(items):
    body = [{"id": i["id"], "kind": i["kind"], "sha256": i["sha256"], "must_retain": bool(i.get("must_retain"))}
            for i in sorted(items, key=lambda x: x["id"])]
    return hashlib.sha256(json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()).hexdigest()


def _int(v, what, lo=0, hi=10 ** 9):
    if isinstance(v, bool) or not isinstance(v, int) or not (lo <= v <= hi):
        raise Bad(f"{what} must be an integer in [{lo}, {hi}]")
    return v


def evaluate(record):
    checks, summary = [], {"retained": {}, "dropped": []}

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

    try:
        if not isinstance(record, dict) or record.get("schema") != "cain42.a75.context.compaction.v1":
            raise Bad("not a cain42.a75.context.compaction.v1 record")
        src, comp = record.get("source"), record.get("compaction")
        if not all(isinstance(x, dict) for x in (src, comp)):
            raise Bad("source and compaction must be objects")
        items = src.get("items")
        if not isinstance(items, list) or not items or len(items) > 5000:
            raise Bad("source.items must be a non-empty list")
        I = {}
        for it in items:
            if not isinstance(it, dict) or not isinstance(it.get("id"), str) or not _ID.match(it["id"]):
                raise Bad("an item needs an id")
            if it["id"] in I:
                raise Bad(f"duplicate item {it['id']}")
            if it.get("kind") not in KINDS:
                raise Bad(f"{it['id']}: kind must be one of {sorted(KINDS)}")
            if not isinstance(it.get("text"), str) or not it["text"].strip():
                raise Bad(f"{it['id']}: text required")
            if it.get("sha256") != text_hash(it["text"]):
                raise Bad(f"{it['id']}: sha256 does not cover the text")
            I[it["id"]] = it
        c("binding.context_hash", src.get("context_sha256") == context_hash(items),
          "the context hash covers exactly these items and their must-retain flags")

        source_tokens = _int(comp.get("source_tokens"), "compaction.source_tokens", 1)
        summary_tokens = _int(comp.get("summary_tokens"), "compaction.summary_tokens", 1)
        max_ratio = float(comp.get("max_compression_ratio", 1.0))
        if not math.isfinite(max_ratio) or not (0.0 < max_ratio <= 1.0):
            raise Bad("compaction.max_compression_ratio must be in (0, 1]")
        ratio = summary_tokens / source_tokens
        c("compression.smaller", summary_tokens < source_tokens, f"{summary_tokens} < {source_tokens} tokens")
        c("compression.within_ratio", ratio <= max_ratio, f"ratio {round(ratio, 4)} <= {max_ratio}")
        summary["ratio"] = round(ratio, 4)

        retained = comp.get("retained")
        if not isinstance(retained, list):
            raise Bad("compaction.retained must be a list")
        kept = {}
        for r in retained:
            if not isinstance(r, dict) or not isinstance(r.get("id"), str):
                raise Bad("a retained entry needs an id")
            if r["id"] in kept:
                raise Bad(f"retained {r['id']} twice")
            kept[r["id"]] = r
            c(f"retained.{r['id']}.exists", r["id"] in I, "the retained id is in the source")
            c(f"retained.{r['id']}.verbatim", r["id"] in I and r.get("quote_sha256") == I[r["id"]]["sha256"],
              "the retained quote hashes to the source text")
            summary["retained"][r["id"]] = bool(r["id"] in I and r.get("quote_sha256") == I[r["id"]]["sha256"])

        dropped = comp.get("dropped_ids")
        if not isinstance(dropped, list) or not all(isinstance(x, str) for x in dropped):
            raise Bad("compaction.dropped_ids must be a list of ids")
        summary["dropped"] = sorted(dropped)
        for iid in sorted(I):
            it = I[iid]
            if it.get("must_retain") is True:
                c(f"{iid}.must_retain_kept", iid in kept and kept[iid].get("quote_sha256") == it["sha256"],
                  "kept verbatim in the summary" if iid in kept else "missing from the summary")
                c(f"{iid}.must_retain_not_dropped", iid not in dropped, "not in the dropped list" if iid not in dropped else "in the dropped list")
        added = sorted(set(kept) - set(I))
        c("retention.no_invented_items", not added, f"invented: {added}" if added else "no invented item")

        claims = record.get("claims") if isinstance(record.get("claims"), dict) else {}
        computed = {
            "all_critical_retained": all(i in kept and kept[i].get("quote_sha256") == I[i]["sha256"]
                                         for i in I if I[i].get("must_retain") is True),
            "no_invented_items": not added,
            "compressed": summary_tokens < source_tokens and ratio <= max_ratio,
        }
        for k in sorted(claims):
            c(f"claims.{k}", k in computed and claims[k] == computed[k], f"claimed {claims[k]!r}, computed {computed.get(k)!r}")
        summary["computed"] = computed
    except Bad as e:
        checks.append({"id": "input.well_formed", "ok": False, "detail": str(e)})
        return "LOST", checks, summary
    except Exception as e:  # noqa: BLE001
        checks.append({"id": "input.error", "ok": False, "detail": f"{type(e).__name__}: {e}"[:200]})
        return "LOST", checks, summary
    return ("PRESERVED" if all(x["ok"] for x in checks) else "LOST"), 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.context.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-015, {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))
