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

A personal agent wants to use personal context for a mobile action. The review recomputes whether the use is consented,
purpose-bound and minimal:

  consent     every requested data class has a live, unrevoked consent whose purpose matches the action and whose scope
              includes that class
  prohibited  no requested class is on the policy's prohibited list
  purpose     every access-log entry uses the same purpose, and context records are not reused for another purpose
  minimal     the action requests exactly the classes it declares necessary (no context broadening)
  retention   the context retention is within the policy maximum
  claims      every boolean the record asserts matches what this review computed

Verdict: COMPLIANT, else NON_COMPLIANT with every failing check named. Malformed input is NON_COMPLIANT.
It reviews the context-use record; it does not read the user's device.
"""
from __future__ import annotations

import math
import re

_ID = re.compile(r"^[A-Za-z0-9_.:\-]{1,80}$")
CLASSES = {"health", "location", "contacts", "preferences", "financial", "messages"}


class Bad(ValueError):
    pass


def _num(v, what, lo=0.0, hi=1e12):
    if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) or not (lo <= v <= hi):
        raise Bad(f"{what} must be a finite number in [{lo}, {hi}]")
    return float(v)


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

    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.personal.context.v1":
            raise Bad("not a cain42.a75.personal.context.v1 record")
        ctx, cons, act, pol = record.get("context"), record.get("consents"), record.get("action"), record.get("policy")
        if not all(isinstance(x, dict) for x in (ctx, act, pol)) or not isinstance(cons, list):
            raise Bad("context, action, policy objects and consents list are required")
        now = _num(act.get("now"), "action.now")
        purpose = act.get("purpose")
        if not isinstance(purpose, str) or not purpose.strip():
            raise Bad("action.purpose required")
        requested = act.get("requested_classes")
        necessary = act.get("necessary_classes")
        if not isinstance(requested, list) or not requested or not all(x in CLASSES for x in requested):
            raise Bad("action.requested_classes must be a non-empty list of known classes")
        if not isinstance(necessary, list) or not all(x in CLASSES for x in necessary):
            raise Bad("action.necessary_classes must be a list of known classes")
        prohibited = pol.get("prohibited_classes") if isinstance(pol.get("prohibited_classes"), list) else []
        max_ret = _num(pol.get("max_retention_days"), "policy.max_retention_days", 0.0, 100000.0)

        C = {}
        for cn in cons:
            if not isinstance(cn, dict) or not isinstance(cn.get("id"), str) or not _ID.match(cn["id"]):
                raise Bad("a consent needs an id")
            if not isinstance(cn.get("granted_by"), str) or not cn["granted_by"]:
                raise Bad(f"{cn['id']}: granted_by required")
            if not isinstance(cn.get("scopes"), list):
                raise Bad(f"{cn['id']}: scopes must be a list")
            C[cn["id"]] = cn

        for cls in sorted(set(requested)):
            matching = [cn for cn in C.values()
                        if cls in cn.get("scopes", []) and cn.get("purpose") == purpose
                        and _num(cn.get("expires_at"), "consent.expires_at") > now and cn.get("revoked") is not True]
            c(f"consent.{cls}", bool(matching), f"live purpose-bound consent for {cls!r}")
            summary["class_consent"][cls] = bool(matching)
            c(f"prohibited.{cls}", cls not in prohibited, f"{cls} not prohibited")
        c("minimal.requested", set(requested) == set(necessary), f"requested {requested} vs necessary {necessary}")

        retention = _num(ctx.get("retention_days"), "context.retention_days", 0.0)
        c("retention.within_policy", retention <= max_ret, f"{retention} <= {max_ret} days")

        records = ctx.get("records")
        if not isinstance(records, list) or not records:
            raise Bad("context.records must be a non-empty list")
        record_ids = set()
        for r in records:
            if not isinstance(r, dict) or not isinstance(r.get("id"), str) or not _ID.match(r["id"]):
                raise Bad("a context record needs an id")
            if r["id"] in record_ids:
                raise Bad(f"duplicate context record {r['id']}")
            record_ids.add(r["id"])
            if r.get("class") not in CLASSES:
                raise Bad(f"{r['id']}: class must be known")
            c(f"record.{r['id']}.requested", r["class"] in requested, f"class {r['class']!r} requested")
            c(f"record.{r['id']}.consent", r.get("consent_id") in C, f"consent {r.get('consent_id')!r}")
            c(f"record.{r['id']}.purpose", r.get("purpose") == purpose, f"purpose {r.get('purpose')!r}")

        log = record.get("access_log") if isinstance(record.get("access_log"), list) else []
        for k, e in enumerate(log):
            if not isinstance(e, dict):
                raise Bad("an access-log entry must be an object")
            c(f"log.{k}.record", e.get("record_id") in record_ids, f"record {e.get('record_id')!r}")
            c(f"log.{k}.purpose", e.get("purpose") == purpose, f"purpose {e.get('purpose')!r}")
            _num(e.get("at"), f"log.{k}.at")
            c(f"log.{k}.agent", e.get("agent") == act.get("agent"), f"agent {e.get('agent')!r}")

        claims = record.get("claims") if isinstance(record.get("claims"), dict) else {}
        computed = {
            "consent_valid": all(summary["class_consent"].values()),
            "purpose_bound": all(r.get("purpose") == purpose for r in records if isinstance(r, dict))
            and all(e.get("purpose") == purpose for e in log if isinstance(e, dict)),
            "retention_ok": retention <= max_ret,
        }
        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
        summary["requested"] = sorted(set(requested))
    except Bad as e:
        checks.append({"id": "input.well_formed", "ok": False, "detail": str(e)})
        return "NON_COMPLIANT", checks, summary
    except Exception as e:  # noqa: BLE001
        checks.append({"id": "input.error", "ok": False, "detail": f"{type(e).__name__}: {e}"[:200]})
        return "NON_COMPLIANT", checks, summary
    return ("COMPLIANT" if all(x["ok"] for x in checks) else "NON_COMPLIANT"), 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.personal.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-029, {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))
