#!/usr/bin/env python3
"""Standalone CAIN-42 E68 evidence verifier.

Imports only the Python standard library and `cryptography` (Ed25519). It does not import CAIN, does not contact
the coordinator, and does not trust the ClawX frontend. Everything is recomputed from files in the bundle.

    python3 verify_e68_offline.py <bundle-dir> [--authority-pub <base64>] [--json]

Exit code 0 = VERIFIED, 1 = NOT_VERIFIED, 2 = usage error.

Trust assumption: the bundle signature is checked against manifest.authority_pub. Pass --authority-pub (pinned out
of band) to require a specific key. A local bundle is not externally anchored; see anchoring.json.
"""
from __future__ import annotations

import base64
import hashlib
import json
import math
import os
import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Mapping, Optional

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric import ed25519

D_GRANT = "cain66.child.grant.v1"
D_TICKET = "cain68.op.ticket.v1"
D_RESULT = "cain68.worker.result.v1"
D_VERIFY = "cain68.verification.record.v1"
D_BUNDLE = "cain68.evidence.bundle.v5"
SCHEMA = "cain68.mission.evidence.bundle.v5"
EXEC_CLASSES = {"REAL_PROVIDER", "REAL_LOCAL_MODEL", "REAL_REMOTE_PROVIDER", "LOCAL_HTTP_TEST",
                "MOCK_PROVIDER", "REPLAY", "SIMULATION", "BLOCKED", "ERROR"}


def norm(o: Any) -> Any:
    if isinstance(o, float):
        if not math.isfinite(o):
            raise ValueError("NON_FINITE_NUMBER")
        return int(o) if o.is_integer() else o
    if isinstance(o, Mapping):
        return {str(k): norm(v) for k, v in o.items()}
    if isinstance(o, (list, tuple)):
        return [norm(v) for v in o]
    return o


def canon(o: Any) -> bytes:
    return json.dumps(norm(o), sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()


def sha(o: Any) -> str:
    return hashlib.sha256(canon(o)).hexdigest()


def verify_sig(pub: str, sig: str, domain: str, body: Mapping[str, Any]) -> bool:
    try:
        ed25519.Ed25519PublicKey.from_public_bytes(base64.b64decode(pub)).verify(
            base64.b64decode(sig), domain.encode() + b"\0" + canon(body))
        return True
    except (InvalidSignature, ValueError, TypeError):
        return False


def read(bundle: str, name: str) -> Any:
    p = os.path.join(bundle, name)
    if not os.path.isfile(p):
        return None
    with open(p, "rb") as f:
        return json.loads(f.read().decode())


def digest_file(path: str) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def _iso(ts: str) -> float:
    return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp()


def verify(bundle: str, *, pinned_pub: Optional[str] = None) -> Dict[str, Any]:
    problems: List[str] = []
    checks: List[str] = []
    m = read(bundle, "manifest.json")
    if not isinstance(m, dict):
        return {"ok": False, "problems": ["manifest_missing"], "checks": []}
    if m.get("schema") != SCHEMA:
        problems.append("schema_not_v5")
    body = {k: v for k, v in m.items() if k != "signature"}
    pub = str(m.get("authority_pub") or "")
    if not verify_sig(pub, str(m.get("signature") or ""), D_BUNDLE, body):
        problems.append("manifest_signature_invalid")
    if pinned_pub and pub != pinned_pub:
        problems.append("authority_key_not_pinned")
    collective_mode = bool(m.get("collective_verification"))
    checks.append("manifest_signature")
    # files
    files = m.get("files") or {}
    for name, entry in files.items():
        if name == "artifacts":
            for a, meta in (entry or {}).items():
                p = os.path.join(bundle, "artifacts", a)
                if not os.path.isfile(p):
                    problems.append(f"artifact_missing:{a}")
                elif digest_file(p) != meta.get("sha256", a):
                    problems.append(f"artifact_digest_mismatch:{a}")
            continue
        p = os.path.join(bundle, name)
        if not os.path.isfile(p):
            problems.append(f"file_missing:{name}")
        elif digest_file(p) != entry.get("sha256"):
            problems.append(f"file_digest_mismatch:{name}")
    checks.append("file_digests")
    mission = read(bundle, "mission.json") or {}
    tasks = (read(bundle, "tasks.json") or {}).get("tasks") or []
    workers = (read(bundle, "workers.json") or {}).get("workers") or []
    grants = (read(bundle, "grants.json") or {}).get("grants") or []
    decisions = (read(bundle, "decisions.json") or {}).get("decisions") or []
    tickets = (read(bundle, "tickets.json") or {}).get("tickets") or []
    results = (read(bundle, "results.json") or {}).get("results") or []
    verifications = (read(bundle, "verifications.json") or {}).get("verifications") or []
    models = (read(bundle, "model_calls.json") or {}).get("model_calls") or []
    memory = read(bundle, "memory.json") or {}
    events_doc = read(bundle, "events.json") or {}
    fields = read(bundle, "fields.json") or {}
    endurance = read(bundle, "endurance.json") or {}
    anchoring = read(bundle, "anchoring.json") or {}

    keys = {k: v.get("pub") for k, v in (m.get("authority_keys") or {}).get("keys", {}).items()
            if v.get("state") != "REVOKED"}
    if pub and pub not in keys.values():
        problems.append("manifest_key_not_in_authority_keys")
    # grants
    for g in grants:
        gb = {k: v for k, v in g.items() if k not in ("signature", "ops_used")}
        if not verify_sig(str(g.get("parent_pub") or ""), str(g.get("signature") or ""), D_GRANT, gb):
            problems.append(f"grant_signature_invalid:{g.get('grant_id')}")
        if g.get("parent_pub") not in keys.values():
            problems.append(f"grant_signer_unknown:{g.get('grant_id')}")
    checks.append("grants")
    # tickets
    tmap = {t["ticket_id"]: t for t in tickets}
    for t in tickets:
        tb = t.get("body") or {}
        if not verify_sig(keys.get(t.get("kid"), ""), str(t.get("sig") or ""), D_TICKET, tb):
            problems.append(f"ticket_signature_invalid:{t.get('ticket_id')}")
        if float(tb.get("expires_at", 0)) <= float(tb.get("issued_at", 0)):
            problems.append(f"ticket_not_positive_ttl:{t.get('ticket_id')}")
    checks.append("tickets")
    # results
    wmap = {w["worker_id"]: w for w in workers}
    rmap: Dict[str, Dict[str, Any]] = {}
    for r in results:
        b = r.get("body") or {}
        w = wmap.get(str(b.get("worker_id") or ""))
        if w is None:
            problems.append(f"result_worker_unknown:{b.get('worker_id')}")
            continue
        if not verify_sig(str(w.get("pub") or ""), str(r.get("sig") or ""), D_RESULT, b):
            problems.append(f"result_signature_invalid:{b.get('task_id')}")
        tk = tmap.get(str(b.get("ticket_id") or ""))
        if tk is None:
            problems.append(f"result_ticket_unknown:{b.get('task_id')}")
        else:
            tb = tk.get("body") or {}
            if tb.get("worker_id") != b.get("worker_id") or tb.get("task_id") != b.get("task_id") \
                    or int(tb.get("attempt", -1)) != int(b.get("attempt", -2)):
                problems.append(f"result_ticket_binding_mismatch:{b.get('task_id')}")
        art = os.path.join(bundle, "artifacts", str(b.get("artifact_sha256") or ""))
        if b.get("ok"):
            if not os.path.isfile(art):
                problems.append(f"result_artifact_missing:{b.get('task_id')}")
            elif digest_file(art) != b.get("artifact_sha256"):
                problems.append(f"result_artifact_digest_mismatch:{b.get('task_id')}")
        rmap[str(b.get("task_id"))] = r
    checks.append("results")
    # authorization decisions: every executed task has an ALLOW decision
    allow = {(d.get("task_id"), d.get("outcome")) for d in decisions}
    for t in tasks:
        if t.get("state") in ("DONE", "VERIFIED", "VERIFICATION_FAILED", "FAILED") and t.get("ticket_id"):
            if (t["task_id"], "ALLOW") not in allow:
                problems.append(f"task_without_allow_decision:{t['task_id']}")
    checks.append("authorization_decisions")
    # verification independence and outcomes
    vmap = {v["task_id"]: v for v in verifications}
    for t in tasks:
        if t.get("kind") != "EXECUTE":
            continue
        if t.get("state") in ("BLOCKED", "FAILED", "CANCELLED", "PENDING", "READY", "LEASED", "RUNNING"):
            continue
        ver_meta = t.get("verification") or {}
        if collective_mode and (ver_meta.get("method") in ("none", "NONE")):
            continue
        v = vmap.get(t["task_id"])
        if v is None:
            problems.append(f"execute_task_without_verification:{t['task_id']}")
            continue
        if v.get("producer_id") == v.get("verifier_id"):
            problems.append(f"self_verification:{t['task_id']}")
        detail = v.get("detail") or {}
        if detail.get("method") == "INDEPENDENT_RECOMPUTE":
            if detail.get("verifier_digest") != detail.get("producer_digest"):
                problems.append(f"recompute_digest_differs:{t['task_id']}")
        rec = detail.get("receipt") or {}
        rb = {k: x for k, x in rec.items() if k not in ("sig", "kid")}
        if rec and not verify_sig(keys.get(rec.get("kid"), ""), str(rec.get("sig") or ""), D_VERIFY, rec.get("body") or {}):
            problems.append(f"verification_receipt_invalid:{t['task_id']}")
    if collective_mode:
        checks.append("collective_mode_uses_e69_verifier")
    checks.append("verification_independence")
    # mission determination honesty
    if m.get("determination") == "COMPLETED":
        if any(t.get("state") != "VERIFIED" for t in tasks):
            problems.append("completed_mission_has_unverified_tasks")
        if any(not v.get("passed") for v in verifications):
            problems.append("completed_mission_has_failed_verification")
    # event slice
    prev = str(events_doc.get("entry_prev") or "0" * 64)
    for e in events_doc.get("events") or []:
        want = sha({"at": e.get("at"), "tenant": e.get("tenant"), "mission_id": e.get("mission_id"),
                    "type": e.get("type"), "actor": e.get("actor"), "body": e.get("body"), "prev": prev})
        if e.get("prev") != prev or e.get("digest") != want:
            problems.append(f"event_chain_broken:{e.get('seq')}")
            break
        prev = e.get("digest")
    checks.append("event_chain")
    # provider records and secret scan
    for c in models:
        if str(c.get("execution_class")) not in EXEC_CLASSES:
            problems.append(f"provider_class_invalid:{c.get('execution_class')}")
        if c.get("execution_class") == "REAL_PROVIDER" and not c.get("request_digest"):
            problems.append("real_provider_without_request_digest")
    raw = json.dumps({"m": m, "c": models, "e": events_doc}).lower()
    for bad in ("sk-", "bearer ", "authorization:", "\"api_key\":", "x-api-key"):
        if bad in raw:
            problems.append(f"credential_material_present:{bad}")
    checks.append("provider_records")
    for e in memory.get("events") or []:
        if str(e.get("status")) == "QUARANTINED":
            continue
        d = e.get("detail") or {}
        if d.get("authority_effect") not in (None, "none"):
            problems.append(f"memory_grants_authority:{e.get('mem_id')}")
    if memory.get("authority_effect") not in (None, "none"):
        problems.append("memory_bundle_grants_authority")
    checks.append("memory_authority_firewall")
    # endurance labels
    mode = str(endurance.get("mode") or "NOT_RUN")
    if mode == "ACTUAL":
        if endurance.get("started_at") and endurance.get("stopped_at"):
            elapsed = _iso(endurance["stopped_at"]) - _iso(endurance["started_at"])
            if abs(elapsed - float(endurance.get("elapsed_s", 0))) > 2.0:
                problems.append("endurance_elapsed_mismatch")
        if "virtual_hours" in endurance:
            problems.append("virtual_hours_in_actual_mode")
        ws = float(endurance.get("aggregate_worker_seconds", 0))
        if abs(ws / 3600.0 - float(endurance.get("aggregate_worker_hours", 0))) > 0.01:
            problems.append("endurance_worker_hours_mismatch")
    elif mode == "ACCELERATED":
        checks.append("endurance_accelerated_labelled")
    elif mode not in ("NOT_RUN",):
        problems.append(f"endurance_mode_invalid:{mode}")
    checks.append("endurance")
    # anchoring honesty
    if anchoring.get("off_host") == "VERIFIED":
        if not anchoring.get("remote_receipt") or not anchoring.get("verified"):
            problems.append("off_host_claim_without_receipt")
    elif anchoring.get("off_host") not in (None, "BLOCKED", "LOCAL_ONLY", "NOT_CONFIGURED"):
        problems.append(f"anchoring_status_invalid:{anchoring.get('off_host')}")
    checks.append("anchoring")
    # field accounting
    fdoc = fields.get("fields") or {}
    if sum(int(v.get("agents", 0)) for v in fdoc.values()) != len(workers):
        problems.append("field_agent_count_mismatch")
    if int(fields.get("cross_field_verified", 0)) != sum(
            1 for e in (fields.get("cross_field_edges") or []) if e.get("cross_field") and e.get("passed")):
        problems.append("cross_field_count_mismatch")
    checks.append("fields")
    return {"ok": not problems, "checks": checks, "problems": problems,
            "mission_id": m.get("mission_id"), "determination": m.get("determination"),
            "tasks": len(tasks), "verified": sum(1 for t in tasks if t.get("state") == "VERIFIED"),
            "workers": len(workers), "fields": m.get("fields"),
            "cross_field_verified": m.get("cross_field_verified"),
            "provider_calls": len(models), "endurance_mode": mode,
            "anchoring": anchoring.get("off_host") or anchoring.get("status")}


def main(argv=None) -> int:
    argv = list(sys.argv[1:] if argv is None else argv)
    if not argv:
        print("usage: verify_e68_offline.py <bundle> [--authority-pub <b64>] [--json]")
        return 2
    bundle = argv[0]
    pinned = None
    if "--authority-pub" in argv:
        pinned = argv[argv.index("--authority-pub") + 1]
    r = verify(bundle, pinned_pub=pinned)
    if "--json" in argv:
        print(json.dumps(r, indent=1, sort_keys=True))
    else:
        print(f"bundle: {bundle}")
        print(f"checks: {', '.join(r['checks'])}")
        print(f"mission: {r.get('mission_id')} determination: {r.get('determination')} "
              f"tasks: {r.get('tasks')} verified: {r.get('verified')} workers: {r.get('workers')}")
        print(f"fields: {', '.join(r.get('fields') or [])} cross-field verified: {r.get('cross_field_verified')}")
        print(f"provider calls: {r.get('provider_calls')} endurance: {r.get('endurance_mode')} "
              f"anchoring: {r.get('anchoring')}")
        print("RESULT: VERIFIED" if r["ok"] else "RESULT: NOT_VERIFIED")
        for p in r["problems"]:
            print(f"  - {p}")
    return 0 if r["ok"] else 1


if __name__ == "__main__":
    raise SystemExit(main())
