#!/usr/bin/env python3
"""
ConversaSure evidence manifest verifier - independent reference implementation.

Written separately from the platform's TypeScript verifier (lib/verifier/core.ts)
and shares no code with it, so an auditor does not have to trust ConversaSure's
own software to check an evidence pack. Standard library only (Python 3.9+).

    python3 verify_manifest.py manifest.json [evidence-pack.pdf ...]

Exit code 0: verified (possibly with warnings). 1: verification failed. 2: usage.

What it checks (hash rules: docs/verifier/README.md):
  * the manifest digest (detects any edit to the manifest)
  * every Decision Integrity Record: content re-hashed, chain links, sequence
  * every governance record with provenance: content re-hashed, chain links
  * every outcome record: content re-hashed, chain links
  * any PDF given: its SHA-256 matches a document the manifest says was issued
It does not check the Ed25519 signature (the standard library has no Ed25519);
use the browser or command-line verifier for that, or OpenSSL (see README).
"""
import hashlib
import json
import math
import sys
from datetime import datetime, timezone

FORMAT = "conversasure.evidence-manifest/1"


# ---------------------------------------------------------------------------
# JavaScript-compatible JSON serialisation
# ---------------------------------------------------------------------------
def js_number(n):
    """Format a number the way JavaScript's JSON.stringify does."""
    if isinstance(n, bool):
        return "true" if n else "false"
    if isinstance(n, int):
        return str(n)
    if math.isnan(n) or math.isinf(n):
        return "null"
    if n == int(n) and abs(n) < 1e21:
        return str(int(n))
    r = repr(n)
    if "e" in r:
        mant, exp = r.split("e")
        exp_i = int(exp)
        if -7 < exp_i < 21:
            # JS uses plain notation in this range
            return format(n, "f").rstrip("0").rstrip(".")
        return f"{mant}e{'+' if exp_i > 0 else '-'}{abs(exp_i)}"
    return r


def js_string(s):
    out = ['"']
    for ch in s:
        o = ord(ch)
        if ch == '"':
            out.append('\\"')
        elif ch == "\\":
            out.append("\\\\")
        elif ch == "\b":
            out.append("\\b")
        elif ch == "\f":
            out.append("\\f")
        elif ch == "\n":
            out.append("\\n")
        elif ch == "\r":
            out.append("\\r")
        elif ch == "\t":
            out.append("\\t")
        elif o < 0x20 or 0xD800 <= o <= 0xDFFF:
            out.append("\\u%04x" % o)
        else:
            out.append(ch)
    out.append('"')
    return "".join(out)


def utf16_key(k):
    return k.encode("utf-16-be", "surrogatepass")


def is_array_index(k):
    # JavaScript orders integer-like ("array index") keys first, ascending.
    return k.isdigit() and (k == "0" or not k.startswith("0")) and int(k) < 2**32 - 1


def js_value(v, key_order):
    if v is None:
        return "null"
    if isinstance(v, bool):
        return "true" if v else "false"
    if isinstance(v, (int, float)):
        return js_number(v)
    if isinstance(v, str):
        return js_string(v)
    if isinstance(v, list):
        return "[" + ",".join(js_value(x, key_order) for x in v) + "]"
    if isinstance(v, dict):
        keys = key_order(list(v.keys()))
        return "{" + ",".join(js_string(k) + ":" + js_value(v[k], key_order) for k in keys) + "}"
    raise TypeError(f"unsupported type {type(v)}")


def sorted_keys(keys):
    """stableStringify: plain UTF-16 code-unit sort."""
    return sorted(keys, key=utf16_key)


def js_object_keys(keys):
    """JSON.stringify of an object whose keys were inserted in sorted order."""
    s = sorted(keys, key=utf16_key)
    idx = sorted((k for k in s if is_array_index(k)), key=int)
    return idx + [k for k in s if not is_array_index(k)]


def insertion_order(keys):
    return keys


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


def iso_ms(ts):
    """new Date(ts).toISOString(): UTC, millisecond precision, 'Z'."""
    t = ts.strip().replace("Z", "+00:00")
    if " " in t and "T" not in t:
        t = t.replace(" ", "T", 1)
    # Python < 3.11 needs exactly 3 or 6 fractional digits
    if "." in t:
        head, frac = t.split(".", 1)
        digits = "".join(c for c in frac if c.isdigit())
        tz = frac[len(digits):]
        t = head + "." + (digits + "000000")[:6] + tz
    dt = datetime.fromisoformat(t)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    dt = dt.astimezone(timezone.utc)
    return dt.strftime("%Y-%m-%dT%H:%M:%S.") + "%03d" % (dt.microsecond // 1000) + "Z"


# ---------------------------------------------------------------------------
# Verification
# ---------------------------------------------------------------------------
class Result:
    def __init__(self):
        self.fail = []
        self.warn = []
        self.ok = []

    def check(self, cond, label, bad="", warn=False):
        if cond:
            self.ok.append(label)
        elif warn:
            self.warn.append(f"{label}: {bad}")
        else:
            self.fail.append(f"{label}: {bad}")


def dir_body(record):
    p = record.get("provenance") or {}
    return {
        "case_id": record.get("case_id"),
        "decision_integrity_version": record.get("decision_integrity_version"),
        "dependency_status": record.get("dependency_status"),
        "dependency_conflicts": record.get("dependency_conflicts"),
        "summary": record.get("summary"),
        "evidential_position": record.get("evidential_position"),
        "decision_challenge": record.get("decision_challenge"),
        "governance_position": record.get("governance_position"),
        "source_assessments": p.get("source_assessments"),
        "generated_by": p.get("generated_by"),
        "generated_at": p.get("generated_at"),
    }


def gov_canonical(e):
    obj = {
        "case_id": e["case_id"],
        "previous_state": e["previous_state"],
        "next_state": e["next_state"],
        "actor": e["actor"],
        "actor_role": e["actor_role"],
        "timestamp": iso_ms(e["evaluated_at"]),
        "outcome": e.get("governance_outcome"),
        "signal_codes": sorted(e.get("signal_codes") or [], key=utf16_key),
        "metadata": {
            "escalation_level": e.get("escalation_level"),
            "execution_permission": e.get("execution_permission"),
            "triggered_by": e.get("triggered_by"),
            "snapshot_hash": e.get("snapshot_hash"),
        },
    }
    return js_value(obj, insertion_order)


def verify(m, pdfs):
    r = Result()
    if not isinstance(m, dict) or m.get("format") != FORMAT:
        r.fail.append(f"not a ConversaSure evidence manifest (expected {FORMAT})")
        return r
    case = m["case_reference"]

    rest = {k: v for k, v in m.items() if k not in ("manifest_digest", "signature")}
    digest = sha256(js_value(rest, sorted_keys))
    r.check(digest == m.get("manifest_digest"), "manifest digest",
            f"computed {digest}, manifest says {m.get('manifest_digest')}")
    if not m.get("signature"):
        r.warn.append("manifest is unsigned: confirm chain heads with ConversaSure's public register")

    # Decision Integrity
    dirs = sorted(m["chains"]["decision_integrity"], key=lambda e: e["record_sequence"])
    for i, e in enumerate(dirs):
        lab = f"decision integrity v{e['record_sequence']}"
        r.check(e["record_sequence"] == i + 1, lab + " sequence", "gap in sequence")
        r.check((e.get("record") or {}).get("case_id") == case, lab + " case", "different case")
        h = sha256(js_value(dir_body(e["record"]), sorted_keys))
        r.check(h == e["record_hash"], lab + " content hash", f"computed {h}, recorded {e['record_hash']}")
        prev = dirs[i - 1]["record_hash"] if i else None
        r.check(e.get("prev_hash") == prev, lab + " link", f"prev_hash {e.get('prev_hash')} != {prev}")
        ch = sha256(e["record_hash"] + (e.get("prev_hash") or ""))
        r.check(ch == e["chained_hash"], lab + " chained hash", f"computed {ch}")

    # Governance
    big = float("inf")
    gov = sorted(m["chains"]["governance"],
                 key=lambda e: e["record_sequence"] if e.get("record_sequence") is not None else big)
    prev_row = None
    for e in gov:
        lab = f"governance {e.get('record_sequence')}"
        r.check(e["case_id"] == case, lab + " case", "different case")
        if not e.get("row_hash"):
            continue  # predates hash chaining
        if all(e.get(k) is not None for k in ("previous_state", "next_state", "actor", "actor_role")):
            h = sha256(gov_canonical(e))
            r.check(h == e["row_hash"], lab + " content hash", f"computed {h}, recorded {e['row_hash']}")
        expected = sha256(f"{prev_row}:{e['row_hash']}") if prev_row else e["row_hash"]
        if expected != e.get("chained_hash"):
            r.fail.append(f"{lab} chained hash: chain broken (expected {expected})")
        elif e.get("previous_row_hash") != prev_row:
            r.warn.append(f"{lab}: declared previous_row_hash inconsistent; cryptographic link intact")
        prev_row = e["row_hash"]

    # Outcomes
    outs = m["chains"]["outcomes"]
    for i, e in enumerate(outs):
        lab = f"outcome {i + 1}"
        r.check(e["payload"].get("case_id") == case, lab + " case", "different case")
        h = sha256(js_value(e["payload"], js_object_keys))
        r.check(h == e["record_hash"], lab + " content hash", f"computed {h}, recorded {e['record_hash']}")
        prev = outs[i - 1]["chained_hash"] if i else None
        r.check(e.get("prev_hash") == prev, lab + " link", f"prev_hash {e.get('prev_hash')} != {prev}")
        ch = sha256(e["record_hash"] + (e.get("prev_hash") or ""))
        r.check(ch == e["chained_hash"], lab + " chained hash", f"computed {ch}")

    # Documents
    dir_hashes = {e["record_hash"] for e in dirs}
    for d in m["documents"]:
        cited = d.get("dir_record_hash")
        r.check(not cited or cited in dir_hashes, f"{d['document_type']} citation",
                f"cites Decision Integrity Record {cited}, which is not in this chain")
    issued = {d["file_sha256"]: d for d in m["documents"] if d.get("file_sha256")}
    for path in pdfs:
        with open(path, "rb") as f:
            h = hashlib.sha256(f.read()).hexdigest()
        d = issued.get(h)
        r.check(d is not None, f"file {path}",
                f"SHA-256 {h} is not a document this manifest lists as issued")
        if d:
            r.ok.append(f"file {path} is the {d['document_type']} issued {d['generated_at']}")
    return r


def main(argv):
    if len(argv) < 2:
        print(__doc__)
        return 2
    with open(argv[1], encoding="utf-8") as f:
        manifest = json.load(f)
    r = verify(manifest, argv[2:])
    for line in r.ok:
        print("  PASS ", line)
    for line in r.warn:
        print("  WARN ", line)
    for line in r.fail:
        print("  FAIL ", line)
    print()
    if r.fail:
        print(f"RESULT: FAILED ({len(r.fail)} failure(s))")
        return 1
    print("RESULT: VERIFIED" + (f" with {len(r.warn)} warning(s)" if r.warn else ""))
    return 0


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