"""CAIN-A75-002 offline verifier (rules embedded verbatim). Standard library only, no CAIN imports: this file is also embedded verbatim in the
offline verifier, so an outsider re-runs exactly these checks.

A proposal is one physical step an agent wants to send to lab equipment, plus the authority it acts under and the
lab state it claims. The rules decide ALLOW, DENY or REQUIRE_APPROVAL before anything is sent:
  DENY             any check fails (and anything malformed, unknown or out of range)
  REQUIRE_APPROVAL every check passes but the step needs a named human (select agent, controlled substance, acutely
                   toxic reagent) and no matching approval is attached
  ALLOW            every check passes and nothing needs a human, or the required approval is attached
Nothing here contacts an instrument. The verdict is about the supplied proposal and state, not about the world.
"""
from __future__ import annotations

import math
import re
from datetime import datetime, timezone

ACTIONS = {"aspirate", "dispense", "transfer", "mix", "incubate", "centrifuge", "heat", "cool", "shake", "seal", "read"}
LIQUID = {"aspirate", "dispense", "transfer", "mix"}          # move or touch a volume of reagent
NEEDS_DEST = {"dispense", "transfer", "mix", "incubate", "centrifuge", "heat", "cool", "shake", "seal", "read"}
THERMAL = {"heat", "cool", "incubate"}
SPIN = {"centrifuge", "shake"}
ACUTE_TOX = {"H300", "H310", "H330"}                           # GHS acute toxicity, category 1-2 (fatal)
APPROVER_ROLE = {"select_agent": "biosafety_officer", "controlled": "controlled_substance_officer",
                 "acute_toxic": "safety_officer"}
_ID = re.compile(r"^[A-Za-z0-9_.:\-]{1,80}$")
MAX_VOLUME_UL = 1_000_000.0                                    # 1 L: anything above is refused as implausible


class Bad(ValueError):
    pass


def _ts(v, what):
    if not isinstance(v, str) or not v.endswith("Z"):
        raise Bad(f"{what} must be an ISO-8601 UTC time ending in Z")
    try:
        return datetime.strptime(v, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
    except ValueError:
        raise Bad(f"{what} is not YYYY-MM-DDTHH:MM:SSZ")


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


def _id(v, what):
    if not isinstance(v, str) or not _ID.match(v):
        raise Bad(f"{what} must be an identifier ([A-Za-z0-9_.:-], 1-80 chars)")
    return v


def _covers(scope, action, instrument):
    target = f"{action}:{instrument}"
    for s in scope if isinstance(scope, list) else []:
        if isinstance(s, str) and (s == target or s == f"{action}:*" or (s.endswith("*") and target.startswith(s[:-1]))):
            return True
    return False


def evaluate(p):
    """Return (verdict, checks, needs). checks: list of {id, ok, detail}. Never raises: malformed input is DENY."""
    checks, needs = [], []

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

    try:
        if not isinstance(p, dict) or p.get("schema") != "cain42.a75.lab.proposal.v1":
            raise Bad("not a cain42.a75.lab.proposal.v1 proposal")
        now = _ts(p.get("now"), "now")
        agent = _id(p.get("agent"), "agent")
        lab, auth, step, st = p.get("lab"), p.get("authority"), p.get("step"), p.get("state")
        if not all(isinstance(x, dict) for x in (lab, auth, step, st)):
            raise Bad("lab, authority, step and state must be objects")
        bsl = int(_num(lab.get("biosafety_level"), "lab.biosafety_level", 1, 4))
        action = step.get("action")
        if action not in ACTIONS:
            raise Bad(f"unknown action {action!r}")
        inst_id = _id(step.get("instrument"), "step.instrument")
        vol = _num(step.get("volume_ul", 0), "step.volume_ul", 0, MAX_VOLUME_UL)
        if action in LIQUID and vol <= 0:
            raise Bad(f"{action} needs volume_ul > 0")

        # --- authority
        c("authority.subject", auth.get("subject") == agent, f"subject {auth.get('subject')!r}, agent {agent!r}")
        c("authority.not_expired", _ts(auth.get("expires_at"), "authority.expires_at") > now, f"expires {auth.get('expires_at')}")
        c("authority.scope", _covers(auth.get("scope"), action, inst_id), f"{action}:{inst_id}")
        c("authority.max_volume", vol <= _num(auth.get("max_volume_ul", 0), "authority.max_volume_ul", 0, MAX_VOLUME_UL),
          f"{vol} uL vs {auth.get('max_volume_ul')} uL")
        c("authority.max_bsl", True, "")  # placeholder replaced below once the reagent BSL is known

        # --- instrument
        insts = st.get("instruments") if isinstance(st.get("instruments"), dict) else {}
        inst = insts.get(inst_id)
        if c("instrument.known", isinstance(inst, dict), inst_id):
            c("instrument.ready", inst.get("status") == "READY", f"status {inst.get('status')!r}")
            c("instrument.calibrated", _ts(inst.get("calibrated_until"), "calibrated_until") > now, f"calibrated until {inst.get('calibrated_until')}")
            lim = inst.get("limits") if isinstance(inst.get("limits"), dict) else {}
            if action in LIQUID:
                lo, hi = _num(lim.get("min_volume_ul", 0), "min_volume_ul"), _num(lim.get("max_volume_ul", 0), "max_volume_ul")
                c("instrument.volume_range", lo <= vol <= hi, f"{vol} uL vs [{lo}, {hi}]")
            if action in THERMAL:
                t = _num(step.get("temperature_c"), "step.temperature_c", -200, 1200)
                c("instrument.temperature", _num(lim.get("min_temp_c", -200), "min_temp_c", -200, 1200) <= t <= _num(lim.get("max_temp_c", -200), "max_temp_c", -200, 1200),
                  f"{t} C vs [{lim.get('min_temp_c')}, {lim.get('max_temp_c')}]")
            if action in SPIN:
                r = _num(step.get("speed_rpm"), "step.speed_rpm", 0, 200_000)
                c("instrument.speed", r <= _num(lim.get("max_rpm", 0), "max_rpm", 0, 200_000), f"{r} rpm vs max {lim.get('max_rpm')}")

        # --- labware
        ware = st.get("labware") if isinstance(st.get("labware"), dict) else {}
        if action in NEEDS_DEST:
            dst_id = _id(step.get("destination"), "step.destination")
            dst = ware.get(dst_id)
            if c("labware.destination_known", isinstance(dst, dict), dst_id):
                if action in ("dispense", "transfer"):
                    c("labware.destination_unsealed", dst.get("sealed") is False, f"sealed={dst.get('sealed')!r}")
                    cap, cur = _num(dst.get("capacity_ul"), "capacity_ul", 0, MAX_VOLUME_UL), _num(dst.get("contents_ul"), "contents_ul", 0, MAX_VOLUME_UL)
                    c("labware.no_overflow", cur + vol <= cap, f"{cur} + {vol} uL vs capacity {cap}")
        if action in ("aspirate", "transfer"):
            src_id = _id(step.get("source"), "step.source")
            src = ware.get(src_id)
            if c("labware.source_known", isinstance(src, dict), src_id):
                c("labware.source_has_volume", _num(src.get("contents_ul"), "contents_ul", 0, MAX_VOLUME_UL) >= vol,
                  f"{src.get('contents_ul')} uL available, {vol} uL needed")

        # --- reagent, inventory, biosafety, hazards
        reagent_bsl = 1
        if action in LIQUID:
            rid = _id(step.get("reagent"), "step.reagent")
            inv = (st.get("inventory") if isinstance(st.get("inventory"), dict) else {}).get(rid)
            if c("reagent.known", isinstance(inv, dict), rid):
                c("reagent.in_stock", _num(inv.get("available_ul"), "available_ul", 0, MAX_VOLUME_UL) >= vol,
                  f"{inv.get('available_ul')} uL in stock, {vol} uL needed")
                c("reagent.lot_not_expired", _ts(inv.get("expires_at"), "inventory.expires_at") > now, f"lot expires {inv.get('expires_at')}")
                hz = inv.get("hazard") if isinstance(inv.get("hazard"), dict) else None
                if c("reagent.hazard_declared", hz is not None, "every reagent must declare its hazard block"):
                    reagent_bsl = int(_num(hz.get("biosafety_level", 1), "hazard.biosafety_level", 1, 4))
                    c("biosafety.lab_level", reagent_bsl <= bsl, f"reagent BSL-{reagent_bsl}, lab BSL-{bsl}")
                    ghs = hz.get("ghs") if isinstance(hz.get("ghs"), list) else []
                    if hz.get("select_agent") is True:
                        needs.append("select_agent")
                    if hz.get("controlled") is True:
                        needs.append("controlled")
                    if ACUTE_TOX & set(ghs):
                        needs.append("acute_toxic")
                    ex = st.get("exposure") if isinstance(st.get("exposure"), dict) else {}
                    if ACUTE_TOX & set(ghs) or hz.get("select_agent") is True:
                        used = _num(ex.get("hazardous_used_ul", 0), "exposure.hazardous_used_ul", 0, MAX_VOLUME_UL)
                        lim_ = _num(ex.get("hazardous_limit_ul", 0), "exposure.hazardous_limit_ul", 0, MAX_VOLUME_UL)
                        c("exposure.budget", used + vol <= lim_, f"{used} + {vol} uL hazardous vs daily limit {lim_}")
        for chk in checks:   # the authority's BSL ceiling, now that the reagent is known
            if chk["id"] == "authority.max_bsl":
                ceiling = int(_num(auth.get("max_biosafety_level", 1), "authority.max_biosafety_level", 1, 4))
                chk["ok"], chk["detail"] = reagent_bsl <= ceiling, f"reagent BSL-{reagent_bsl}, authority ceiling BSL-{ceiling}"
    except Bad as e:
        checks.append({"id": "input.well_formed", "ok": False, "detail": str(e)})
        return "DENY", checks, needs
    except Exception as e:  # anything unexpected is a refusal, never an allow
        checks.append({"id": "input.error", "ok": False, "detail": f"{type(e).__name__}: {e}"})
        return "DENY", checks, needs

    if not all(x["ok"] for x in checks):
        return "DENY", checks, needs
    approvals = p.get("approvals") if isinstance(p.get("approvals"), list) else []
    missing = []
    for n in sorted(set(needs)):
        role = APPROVER_ROLE[n]
        if not any(isinstance(a, dict) and a.get("role") == role and a.get("for_seq") == step.get("seq")
                   and isinstance(a.get("by"), str) and a.get("by") and a.get("by") != agent for a in approvals):
            missing.append(role)
    if missing:
        checks.append({"id": "human.approval", "ok": False, "detail": "needs approval by: " + ", ".join(missing)})
        return "REQUIRE_APPROVAL", checks, needs
    if needs:
        checks.append({"id": "human.approval", "ok": True, "detail": "approved by: " + ", ".join(APPROVER_ROLE[n] for n in sorted(set(needs)))})
    return "ALLOW", checks, needs


def apply(state, step):
    """Simulated state change after an ALLOWED step, so a protocol is checked step by step: labware volumes, stock
    drawn by a dispense, the daily hazardous-exposure total, and seals. Volumes only; no chemistry is modelled."""
    import copy
    s = copy.deepcopy(state)
    vol = float(step.get("volume_ul", 0) or 0)
    a = step.get("action")
    if a in ("aspirate", "transfer"):
        s["labware"][step["source"]]["contents_ul"] -= vol
    if a in ("dispense", "transfer"):
        s["labware"][step["destination"]]["contents_ul"] += vol
    if a == "dispense":
        s["inventory"][step["reagent"]]["available_ul"] -= vol
    if a in LIQUID:
        hz = (s.get("inventory", {}).get(step.get("reagent")) or {}).get("hazard") or {}
        if hz.get("select_agent") is True or ACUTE_TOX & set(hz.get("ghs") or []):
            ex = s.setdefault("exposure", {})
            ex["hazardous_used_ul"] = float(ex.get("hazardous_used_ul", 0)) + vol
    if a == "seal":
        s["labware"][step["destination"]]["sealed"] = True
    return s


# ---------------------------------------------------------------------------- offline verifier (no CAIN imports)
import base64 as _b64, hashlib as _hl, json as _json, sys as _sys
GENESIS = _hl.sha256(b"cain42.a75.lab.preflight.genesis").hexdigest()


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


def _sha(o):
    return _hl.sha256(_canon(o)).hexdigest()


def 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 = [], 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", "halt_reason")}
        if _sha(body) != r.get("record_hash"): problems.append(f"record {i}: record_hash does not match")
        if body.get("proposal_sha256") != _sha(body.get("proposal")): problems.append(f"record {i}: proposal_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("proposal"))
        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')}")
        if r.get("verdict") != "ALLOW" and i != len(recs) - 1: problems.append(f"record {i}: run continued past a non-ALLOW")
        prev = r.get("record_hash")
    if run.get("run_hash") != _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"({len(recs)} record(s), run verdict {run.get('verdict')})")
    return 0 if not problems else 1


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