#!/usr/bin/env python3
"""
Check a Captilo certificate without Captilo.

Needs the Python 3.8+ standard library, Poppler's pdftotext (only to read the text
printed on the page), and a public Sonic RPC endpoint. No Captilo server is contacted.

    python3 verify_captilo_certificate.py certificate.pdf
    python3 verify_captilo_certificate.py certificate.pdf --rpc https://rpc.soniclabs.com --poppler "C:\\poppler\\bin"

It applies the rules captilo.app and the Captilo app apply (VERIFICATION_RULES.md),
in its own code:

  The file, before anything is looked up
    - one revision only: one cross-reference section, one trailer, one %%EOF. A file
      saved again after issue (an incremental update) is "edited after issue";
    - every object where the cross-reference says it is, no object number twice, no
      object left out. Otherwise two PDF readers could show two different files;
    - one page per proof, at most 20 proofs;
    - each page draws only what the app draws: exactly one JPEG photo, upright, not
      stretched, fully on the page and at least 200 pt on its longer side; at most two
      other images (the logo and the QR code), each at most 100 pt, neither over the
      photo; only Type 1 fonts; no patterns, shadings, layers, clipping, transparency,
      inline images, drawn forms or Type 3 glyphs; no scripts; no added annotations;
    - the record printed on the page is the embedded record, character for character.

  Each proof
    - Picture hash: SHA-256 of the JPEG the proof's page DRAWS (not the largest image);
    - Metadata hash: SHA-256 of the embedded canonical record (metaCanon), byte for byte;
    - both must equal the record in the Captilo registry on Sonic under the Event ID.
    - Online or offline: offline when the signed record says so OR the registry's
      version byte says no nonce was consumed (proofs anchored since 2026-09-04 08:21:52 UTC).

Exit code 0 means every proof is VERIFIED. 1: not verified. 2: not a current Captilo
certificate. 3: the blockchain could not be read (try again; this is not a verdict).
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import urllib.request
import zlib
from datetime import datetime, timezone

# CaptiloProofRegistryV2 on Sonic mainnet (chain ID 146). Every certificate's
# "Sonic TX" link shows this address as the transaction's recipient.
REGISTRY = "0x3A6873D7A36E3FcD89AF557a24de2DaAFf09cB9b"
SONIC_CHAIN_ID = 146
GET_PROOF = "0x1b80bb3a"  # getProof(bytes32)
DEFAULT_RPC = "https://rpc.soniclabs.com"
# The registry's version byte: 3 = the server consumed a capture nonce (online). It
# decides only for proofs anchored at or after this instant (captilo-backend
# PROOF_VERSION_BYTE_SINCE); before it every proof carries 2 and the byte says nothing.
VERSION_NONCE_VERIFIED = 3
VERSION_BYTE_SINCE = int(datetime(2026, 9, 4, 8, 21, 52, tzinfo=timezone.utc).timestamp())

MAX_PROOFS = 20
MAX_STREAM = 8 << 20        # any stream, inflated
MAX_TOTAL = 32 << 20        # all streams together
MAX_CONTENT = 1 << 20       # one page's drawing instructions
MIN_PHOTO_SIDE = 200        # pt, the photo's longer side (the app draws at least 240)
MAX_OTHER_SIDE = 100        # pt, logo 94 x 30, QR code 56 or 62
MAX_OTHER_IMAGES = 2
UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
FORMATS = [
    ("MULTI_PROOF", "captilo.multiproof.metadata", "CAPTILO_V3_MULTI_START", "CAPTILO_V3_MULTI_END"),
    ("SINGLE_PROOF", "captilo.metadata", "CAPTILO_V2_META_START", "CAPTILO_V2_META_END"),
]
RECORD_FIELDS = {"captilo.metadata", "captilo.multiproof.metadata"}
PHOTO_KEYS = {"Type", "Subtype", "Width", "Height", "ColorSpace", "BitsPerComponent", "Filter", "Length"}
RESOURCE_KEYS = {"Font", "XObject", "ExtGState", "ColorSpace", "ProcSet"}
FORBIDDEN_OPS = {"W", "W*", "gs", "BI", "ID", "EI", "sh", "Tr", "BMC", "BDC", "EMC", "MP", "DP", "BX", "EX", "d0", "d1"}


class NotACertificate(Exception):
    pass


class Unreadable(Exception):
    pass


class Name(str):
    pass


class Ref(tuple):
    pass


class HexOrText(bytes):
    pass


class Stream:
    def __init__(self, d, raw):
        self.d, self.raw = d, raw


# ---------------------------------------------------------------------------
# Reading PDF syntax
# ---------------------------------------------------------------------------

WS = b" \t\n\r\x0c\x00"
DELIM = b"()<>[]{}/%"


class Reader:
    def __init__(self, b, pos=0, end=None):
        self.b, self.p, self.end = b, pos, len(b) if end is None else end

    def at(self, kw):
        if self.b[self.p:self.p + len(kw)] != kw:
            return False
        nxt = self.p + len(kw)
        return nxt >= self.end or self.b[nxt] in WS or self.b[nxt] in DELIM

    def skip(self):
        """Whitespace and comments; stops at %%EOF, which is a marker."""
        while self.p < self.end:
            c = self.b[self.p]
            if c in WS:
                self.p += 1
            elif c == 0x25:
                if self.b[self.p:self.p + 5] == b"%%EOF":
                    return
                while self.p < self.end and self.b[self.p] not in b"\r\n":
                    self.p += 1
            else:
                return

    def int(self):
        s = self.p
        while self.p < self.end and 0x30 <= self.b[self.p] <= 0x39:
            self.p += 1
        if s == self.p:
            raise Unreadable("expected a number")
        return int(self.b[s:self.p])

    def keyword(self, kw):
        self.skip()
        if not self.at(kw):
            raise Unreadable("expected " + kw.decode())
        self.p += len(kw)

    def literal(self):
        out, depth = bytearray(), 1
        self.p += 1
        esc = {ord("n"): 10, ord("r"): 13, ord("t"): 9, ord("b"): 8, ord("f"): 12}
        while True:
            if self.p >= self.end:
                raise Unreadable("open string")
            c = self.b[self.p]
            self.p += 1
            if c == 0x5C:
                d = self.b[self.p]
                self.p += 1
                if d in esc:
                    out.append(esc[d])
                elif 0x30 <= d <= 0x37:
                    n = d - 0x30
                    for _ in range(2):
                        if 0x30 <= self.b[self.p] <= 0x37:
                            n = n * 8 + self.b[self.p] - 0x30
                            self.p += 1
                    out.append(n & 0xFF)
                elif d == 0x0D:
                    if self.b[self.p] == 0x0A:
                        self.p += 1
                elif d != 0x0A:
                    out.append(d)
            elif c == 0x28:
                depth += 1
                out.append(c)
            elif c == 0x29:
                depth -= 1
                if depth == 0:
                    return HexOrText(out)
                out.append(c)
            elif c == 0x0D:
                if self.b[self.p] == 0x0A:
                    self.p += 1
                out.append(0x0A)
            else:
                out.append(c)

    def value(self, depth=0):
        if depth > 32:
            raise Unreadable("nested too deep")
        self.skip()
        if self.p >= self.end:
            raise Unreadable("unexpected end")
        b, c = self.b, self.b[self.p]
        if b[self.p:self.p + 2] == b"<<":
            self.p += 2
            d = {}
            while True:
                self.skip()
                if b[self.p:self.p + 2] == b">>":
                    self.p += 2
                    return d
                key = self.value(depth + 1)
                if not isinstance(key, Name):
                    raise Unreadable("dict key")
                if key in d:
                    raise Unreadable("duplicate dict key")
                d[key] = self.value(depth + 1)
        if c == 0x5B:
            self.p += 1
            arr = []
            while True:
                self.skip()
                if b[self.p] == 0x5D:
                    self.p += 1
                    return arr
                arr.append(self.value(depth + 1))
        if c == 0x28:
            return self.literal()
        if c == 0x3C:
            close = b.find(b">", self.p)
            if close < 0 or close >= self.end:
                raise Unreadable("open hex string")
            h = re.sub(rb"\s", b"", b[self.p + 1:close])
            self.p = close + 1
            if len(h) % 2:
                h += b"0"
            try:
                return HexOrText(bytes.fromhex(h.decode("latin-1")))
            except ValueError:
                raise Unreadable("hex string")
        if c == 0x2F:
            self.p += 1
            s = self.p
            while self.p < self.end and b[self.p] not in WS and b[self.p] not in DELIM:
                self.p += 1
            raw = b[s:self.p].decode("latin-1")
            return Name(re.sub(r"#([0-9a-fA-F]{2})", lambda m: chr(int(m.group(1), 16)), raw))
        if c in b"0123456789+-.":
            s = self.p
            self.p += 1
            while self.p < self.end and (0x30 <= b[self.p] <= 0x39 or b[self.p] == 0x2E):
                self.p += 1
            text = b[s:self.p].decode("latin-1")
            if text.isdigit():
                back = self.p
                self.skip()
                if self.p < self.end and 0x30 <= b[self.p] <= 0x39:
                    gen = self.int()
                    self.skip()
                    if self.at(b"R"):
                        self.p += 1
                        return Ref((int(text), gen))
                self.p = back
                return int(text)
            try:
                return float(text)
            except ValueError:
                raise Unreadable("bad number")
        for kw, v in ((b"true", True), (b"false", False), (b"null", None)):
            if self.at(kw):
                self.p += len(kw)
                return v
        raise Unreadable("unknown token")


def inflate(raw, budget, limit=MAX_STREAM):
    d = zlib.decompressobj()
    try:
        out = d.decompress(raw, limit + 1)
    except zlib.error:
        raise Unreadable("stream does not inflate")
    if len(out) > limit:
        raise Unreadable("stream inflates past the limit")
    budget[0] += len(out)
    if budget[0] > MAX_TOTAL:
        raise Unreadable("streams inflate past the limit")
    return out


def decoded(stream, budget, limit=MAX_STREAM):
    f = stream.d.get("Filter")
    if "DecodeParms" in stream.d:
        raise Unreadable("decode parameters")
    if f is None:
        return stream.raw
    if f == "FlateDecode":
        return inflate(stream.raw, budget, limit)
    raise Unreadable("filter")


# ---------------------------------------------------------------------------
# The file: one revision, every object where the cross-reference says (F14, F24)
# ---------------------------------------------------------------------------

def read_file(buf):
    """Returns (objects {num: value}, trailer, problem). problem: None, or a
    (verdict, reason) tuple for a file that is not the one the app wrote."""
    if not buf.startswith(b"%PDF-"):
        raise NotACertificate("not a PDF")
    r = Reader(buf)
    top, tables, startxrefs = [], [], []
    trailers = eofs = 0
    while True:
        r.skip()
        if r.p >= len(buf):
            break
        if buf[r.p:r.p + 5] == b"%%EOF":
            r.p += 5
            eofs += 1
            continue
        if r.at(b"xref"):
            offset = r.p
            r.p += 4
            entries = {}
            while True:
                r.skip()
                if r.at(b"trailer"):
                    break
                first = r.int()
                r.skip()
                count = r.int()
                for i in range(count):
                    r.skip()
                    off = r.int()
                    r.skip()
                    gen = r.int()
                    r.skip()
                    kind = buf[r.p:r.p + 1]
                    r.p += 1
                    if kind not in (b"n", b"f") or first + i in entries:
                        raise Unreadable("xref entry")
                    entries[first + i] = (kind, off, gen)
            r.keyword(b"trailer")
            trailer = r.value()
            if not isinstance(trailer, dict):
                raise Unreadable("trailer")
            trailers += 1
            tables.append((offset, entries, trailer))
            continue
        if r.at(b"startxref"):
            r.p += 9
            r.skip()
            startxrefs.append(r.int())
            continue
        if not (0x30 <= buf[r.p] <= 0x39):
            raise Unreadable("unexpected bytes between objects")
        offset = r.p
        num = r.int()
        r.skip()
        gen = r.int()
        r.keyword(b"obj")
        val = r.value()
        r.skip()
        if r.at(b"stream"):
            if not isinstance(val, dict):
                raise Unreadable("stream without a dictionary")
            r.p += 6
            if buf[r.p:r.p + 2] == b"\r\n":
                r.p += 2
            elif buf[r.p:r.p + 1] in (b"\n", b"\r"):
                r.p += 1
            else:
                raise Unreadable("stream keyword")
            length = val.get("Length")
            if not isinstance(length, int) or isinstance(length, bool) or length < 0:
                raise Unreadable("stream length is not direct")
            if r.p + length > len(buf):
                raise Unreadable("stream runs past the file")
            val = Stream(val, buf[r.p:r.p + length])
            r.p += length
            r.keyword(b"endstream")
        r.keyword(b"endobj")
        top.append((num, gen, offset, val))

    budget = [0]
    objects, duplicate = {}, False
    xref_streams, obj_streams = [], []
    for num, gen, offset, val in top:
        if num in objects:
            duplicate = True
        objects[num] = val
        if isinstance(val, Stream):
            t = val.d.get("Type")
            if t is not None and not isinstance(t, Name):
                raise Unreadable("indirect /Type")
            if t == "XRef":
                xref_streams.append((num, offset, val))
            elif t == "ObjStm":
                obj_streams.append((num, val))
            elif val.d.get("Subtype") != "Image":
                f = val.d.get("Filter")
                if f is not None and f != "FlateDecode":
                    raise Unreadable("filter")
                if f == "FlateDecode":
                    inflate(val.raw, budget)

    packed = {}
    for snum, s in obj_streams:
        body = decoded(s, budget)
        n, first = s.d.get("N"), s.d.get("First")
        if not isinstance(n, int) or not isinstance(first, int) or n < 0 or first > len(body):
            raise Unreadable("object stream header")
        h = Reader(body, 0, first)
        heads = []
        for i in range(n):
            h.skip()
            num = h.int()
            h.skip()
            heads.append((num, h.int()))
        for i, (num, off) in enumerate(heads):
            if num in objects or num in packed:
                duplicate = True
            packed[num] = (snum, i)
            objects[num] = Reader(body, first + off).value()

    sections = len(tables) + len(xref_streams)
    trailer = tables[0][2] if tables else (xref_streams[0][2].d if xref_streams else None)
    if not sections or not startxrefs or not eofs:
        raise Unreadable("no cross-reference")
    if (sections > 1 or trailers > (1 if tables else 0) or len(startxrefs) > 1 or eofs > 1
            or "Prev" in trailer or "XRefStm" in trailer):
        return objects, trailer, ("UNCHECKED", "edited_after_issue")
    if "Encrypt" in trailer:
        raise Unreadable("encrypted")
    altered = ("TAMPERED_CERTIFICATE", "file_structure")
    if duplicate:
        return objects, trailer, altered

    listed = {}
    if tables:
        offset, entries, _ = tables[0]
        if startxrefs[0] != offset:
            return objects, trailer, altered
        for num, (kind, off, gen) in entries.items():
            if kind == b"n":
                listed[num] = ("at", off, gen)
    else:
        _, offset, xs = xref_streams[0]
        if startxrefs[0] != offset:
            return objects, trailer, altered
        body = decoded(xs, budget)
        w, size = xs.d.get("W"), xs.d.get("Size")
        if not (isinstance(w, list) and len(w) == 3 and all(isinstance(v, int) and 0 <= v <= 8 for v in w) and isinstance(size, int)):
            raise Unreadable("xref stream header")
        index = xs.d.get("Index", [0, size])
        if not (isinstance(index, list) and len(index) % 2 == 0 and all(isinstance(v, int) for v in index)):
            raise Unreadable("xref stream index")
        row_len, row = sum(w), 0
        field = lambda at, n: int.from_bytes(body[at:at + n], "big") if n else 0
        for k in range(0, len(index), 2):
            for i in range(index[k + 1]):
                at = row * row_len
                row += 1
                if at + row_len > len(body):
                    raise Unreadable("xref stream too short")
                kind = field(at, w[0]) if w[0] else 1
                a, b = field(at + w[0], w[1]), field(at + w[0] + w[1], w[2])
                num = index[k] + i
                if num in listed:
                    return objects, trailer, altered
                if kind == 1:
                    listed[num] = ("at", a, b)
                elif kind == 2:
                    listed[num] = ("in", a, b)

    by_num = {num: (gen, offset) for num, gen, offset, _ in top}
    for num, e in listed.items():
        if e[0] == "in":
            if packed.get(num) != (e[1], e[2]):
                return objects, trailer, altered
        elif by_num.get(num) != (e[2], e[1]):
            return objects, trailer, altered
    xref_num = xref_streams[0][0] if xref_streams else None
    for num, gen, offset, _ in top:
        if num == xref_num and num not in listed:
            continue
        if listed.get(num) != ("at", offset, gen):
            return objects, trailer, altered
    for num in packed:
        if num not in listed or listed[num][0] != "in":
            return objects, trailer, altered
    return objects, trailer, None


# ---------------------------------------------------------------------------
# What each page draws (VERIFICATION_RULES.md §3)
# ---------------------------------------------------------------------------

class Doc:
    def __init__(self, objects, trailer):
        self.objects, self.trailer = objects, trailer

    def get(self, v, depth=0):
        while isinstance(v, Ref):
            depth += 1
            if depth > 32:
                raise Unreadable("reference loop")
            v = self.objects.get(v[0])
        return v

    def pages(self):
        out, seen = [], set()

        def visit(node_ref, inherited, depth):
            if depth > 32 or len(out) > 1000:
                raise Unreadable("page tree")
            node = self.get(node_ref)
            if not isinstance(node, dict) or id(node) in seen:
                raise Unreadable("page tree")
            seen.add(id(node))
            inh = dict(inherited)
            for k in ("Resources", "MediaBox", "CropBox"):
                if k in node:
                    inh[k] = node[k]
            if node.get("Type") == "Pages":
                for kid in self.get(node.get("Kids")) or []:
                    visit(kid, inh, depth + 1)
            elif node.get("Type") == "Page":
                out.append((node, inh))
            else:
                raise Unreadable("page tree")

        root = self.get(self.trailer.get("Root"))
        if not isinstance(root, dict):
            raise Unreadable("no catalog")
        visit(root.get("Pages"), {}, 0)
        return root, out


def text_of(v):
    if not isinstance(v, bytes):
        return ""
    if v.startswith(b"\xfe\xff"):
        return v[2:].decode("utf-16-be", errors="replace")
    if v.startswith(b"\xef\xbb\xbf"):
        return v[3:].decode("utf-8", errors="replace")
    return v.decode("latin-1")


def tokenize(text):
    out, i, n = [], 0, len(text)
    while i < n:
        c = text[i]
        if c in " \n\r\t\f\0":
            i += 1
        elif c == "%":
            while i < n and text[i] not in "\r\n":
                i += 1
        elif c == "(":
            depth = 0
            while True:
                if text[i] == "\\":
                    i += 1
                elif text[i] == "(":
                    depth += 1
                elif text[i] == ")":
                    depth -= 1
                i += 1
                if i >= n or depth <= 0:
                    break
            out.append("(str)")
        elif c == "<" and text[i + 1:i + 2] != "<":
            while i < n and text[i] != ">":
                i += 1
            i += 1
            out.append("(hex)")
        elif text[i:i + 2] in ("<<", ">>"):
            out.append(text[i:i + 2])
            i += 2
        elif c in "[]{}":
            out.append(c)
            i += 1
        else:
            j = i + 1
            while j < n and text[j] not in " \n\r\t\f\0()<>[]{}/%":
                j += 1
            out.append(text[i:j])
            i = j
    return out


NUM = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)$")


def mul(m, k):
    return [m[0] * k[0] + m[1] * k[2], m[0] * k[1] + m[1] * k[3],
            m[2] * k[0] + m[3] * k[2], m[2] * k[1] + m[3] * k[3],
            m[4] * k[0] + m[5] * k[2] + k[4], m[4] * k[1] + m[5] * k[3] + k[5]]


def box(m):
    xs = [m[4], m[4] + m[0], m[4] + m[2], m[4] + m[0] + m[2]]
    ys = [m[5], m[5] + m[1], m[5] + m[3], m[5] + m[1] + m[3]]
    return min(xs), min(ys), max(xs), max(ys)


def overlap(a, b):
    return max(0, min(a[2], b[2]) - max(a[0], b[0])) * max(0, min(a[3], b[3]) - max(a[1], b[1]))


def within(a, b, tol=1):
    return a[0] >= b[0] - tol and a[1] >= b[1] - tol and a[2] <= b[2] + tol and a[3] <= b[3] + tol


def rect(v):
    if not (isinstance(v, list) and len(v) == 4 and all(isinstance(x, (int, float)) for x in v)):
        raise Unreadable("rectangle")
    return min(v[0], v[2]), min(v[1], v[3]), max(v[0], v[2]), max(v[1], v[3])


def jpeg_size(b):
    if len(b) < 4 or b[0] != 0xFF or b[1] != 0xD8:
        return None
    i = 2
    while i + 9 < len(b):
        if b[i] != 0xFF:
            return None
        m = b[i + 1]
        if m == 0xFF:
            i += 1
            continue
        if m == 0x01 or 0xD0 <= m <= 0xD7:
            i += 2
            continue
        if 0xC0 <= m <= 0xCF and m not in (0xC4, 0xC8, 0xCC):
            return int.from_bytes(b[i + 7:i + 9], "big"), int.from_bytes(b[i + 5:i + 7], "big")
        i += 2 + int.from_bytes(b[i + 2:i + 4], "big")
    return None


def field_name(doc, annot):
    parts, node = [], annot
    for _ in range(10):
        if not isinstance(node, dict):
            break
        if "T" in node:
            parts.insert(0, text_of(doc.get(node["T"])))
        node = doc.get(node.get("Parent"))
    return ".".join(parts)


def check_page(doc, page, inh, n):
    """Returns (photo JPEG bytes or None, problem or None)."""
    tampered = lambda code: (None, ("TAMPERED_CERTIFICATE", code))
    if "AA" in page:
        return tampered("script")
    media = rect(doc.get(inh.get("MediaBox")))
    crop = rect(doc.get(inh.get("CropBox"))) if "CropBox" in inh else media
    visible = (max(media[0], crop[0]), max(media[1], crop[1]), min(media[2], crop[2]), min(media[3], crop[3]))

    resources = doc.get(inh.get("Resources")) or {}
    if not isinstance(resources, dict):
        return tampered("resources")
    if any(k not in RESOURCE_KEYS for k in resources):
        return tampered("resources")
    fonts = doc.get(resources.get("Font"))
    if fonts is not None:
        if not isinstance(fonts, dict):
            return tampered("font")
        for ref in fonts.values():
            font = doc.get(ref)
            if not isinstance(font, dict) or font.get("Subtype") != "Type1":
                return tampered("font")
    xobjects = doc.get(resources.get("XObject")) or {}

    try:
        contents = doc.get(page.get("Contents"))
        streams = [doc.get(s) for s in contents] if isinstance(contents, list) else ([contents] if contents is not None else [])
        parts, total = [], 0
        for s in streams:
            if not isinstance(s, Stream):
                raise Unreadable("content")
            f = s.d.get("Filter")
            if f is None:
                data = s.raw
            elif f == "FlateDecode":
                data = inflate(s.raw, [0], MAX_CONTENT)
            else:
                raise Unreadable("content filter")
            total += len(data)
            if total > MAX_CONTENT:
                raise Unreadable("content too large")
            parts.append(data.decode("latin-1"))
        toks = tokenize("\n".join(parts))
    except (Unreadable, IndexError):
        return tampered("content_unreadable")

    ctm, stack, photos, others = [1, 0, 0, 1, 0, 0], [], [], []
    for i, t in enumerate(toks):
        if t in FORBIDDEN_OPS:
            return tampered("operator_" + t)
        if t == "q":
            stack.append(ctm)
        elif t == "Q":
            if not stack:
                return tampered("bad_content")
            ctm = stack.pop()
        elif t == "cm":
            args = toks[max(0, i - 6):i]
            if len(args) != 6 or not all(NUM.match(a) for a in args):
                return tampered("bad_content")
            ctm = mul([float(a) for a in args], ctm)
        elif t == "Do":
            name = toks[i - 1] if i else ""
            xo = doc.get(xobjects.get(name[1:])) if name.startswith("/") and isinstance(xobjects, dict) else None
            if not isinstance(xo, Stream):
                return tampered("bad_content")
            if xo.d.get("Subtype") != "Image":
                return tampered("drawn_form")
            f = xo.d.get("Filter")
            is_jpeg = f == "DCTDecode" or (isinstance(f, list) and "DCTDecode" in f)
            (photos if is_jpeg else others).append((xo, ctm))

    if len(photos) > 1:
        return tampered("second_photo")
    if len(others) > MAX_OTHER_IMAGES:
        return tampered("extra_image")
    for _, m in others:
        b = box(m)
        if b[2] - b[0] > MAX_OTHER_SIDE or b[3] - b[1] > MAX_OTHER_SIDE:
            return tampered("extra_image")

    photo = None
    if photos:
        xo, m = photos[0]
        d = xo.d
        if any(k not in PHOTO_KEYS for k in d) or d.get("Filter") != "DCTDecode":
            return tampered("photo_settings")
        if d.get("ColorSpace") not in ("DeviceRGB", "DeviceGray") or d.get("BitsPerComponent") != 8:
            return tampered("photo_settings")
        w, h = d.get("Width"), d.get("Height")
        if jpeg_size(xo.raw) != (w, h) or not w or not h:
            return tampered("photo_settings")
        if m[1] != 0 or m[2] != 0 or m[0] <= 0 or m[3] <= 0:
            return tampered("photo_transformed")
        if abs(m[0] / m[3] - w / h) / (w / h) > 0.02:
            return tampered("photo_transformed")
        r = box(m)
        if not within(r, visible):
            return tampered("photo_transformed")
        if max(r[2] - r[0], r[3] - r[1]) < MIN_PHOTO_SIDE:
            return tampered("photo_size")
        for _, om in others:
            if overlap(box(om), r) > 1:
                return tampered("image_over_photo")
        photo = xo.raw

    for ref in doc.get(page.get("Annots")) or []:
        a = doc.get(ref)
        if not isinstance(a, dict):
            return None, ("UNCHECKED", "edited_after_issue")
        if a.get("Subtype") == "Link" and "AP" not in a:
            continue
        rr = doc.get(a.get("Rect"))
        off_page = isinstance(rr, list) and overlap(rect(rr), visible) == 0
        if a.get("Subtype") == "Widget" and off_page and field_name(doc, a) in RECORD_FIELDS:
            continue
        return None, ("UNCHECKED", "edited_after_issue")
    return photo, None


def document_problem(doc, root):
    if "OCProperties" in root:
        return ("TAMPERED_CERTIFICATE", "layers")
    if "OpenAction" in root or "AA" in root:
        return ("TAMPERED_CERTIFICATE", "script")
    names = doc.get(root.get("Names"))
    if isinstance(names, dict) and "JavaScript" in names:
        return ("TAMPERED_CERTIFICATE", "script")
    return None


def embedded_record(doc, root):
    """The Captilo record from its form field only (never a search of the file)."""
    form = doc.get(root.get("AcroForm"))
    fields = {}

    def visit(ref, prefix, depth):
        f = doc.get(ref)
        if depth > 10 or not isinstance(f, dict):
            return
        name = prefix + ("." if prefix and "T" in f else "") + (text_of(doc.get(f["T"])) if "T" in f else "")
        if "V" in f:
            fields[name] = text_of(doc.get(f["V"]))
        for kid in doc.get(f.get("Kids")) or []:
            visit(kid, name, depth + 1)

    for ref in (doc.get(form.get("Fields")) if isinstance(form, dict) else None) or []:
        visit(ref, "", 0)
    for kind, name, start, end in FORMATS:
        text = fields.get(name, "")
        a = text.find(start)
        b = text.find(end, a + len(start)) if a >= 0 else -1
        if b < 0:
            continue
        try:
            return kind, json.loads(text[a + len(start):b])
        except ValueError:
            raise NotACertificate("The certificate's embedded record could not be read.")
    raise NotACertificate("This PDF is not a Captilo certificate.")


# ---------------------------------------------------------------------------
# The chain
# ---------------------------------------------------------------------------

def rpc_call(rpc, method, params):
    body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode()
    request = urllib.request.Request(
        rpc, data=body, headers={"Content-Type": "application/json", "User-Agent": "captilo-independent-verifier"}
    )
    with urllib.request.urlopen(request, timeout=30) as response:
        reply = json.load(response)
    if "error" in reply:
        raise RuntimeError(reply["error"])
    return reply["result"]


def get_proof(rpc, registry, event_id):
    key = event_id.replace("-", "").lower().rjust(64, "0")
    result = rpc_call(rpc, "eth_call", [{"to": registry, "data": GET_PROOF + key}, "latest"])
    if not isinstance(result, str) or len(result) < 2 + 64 * 8:
        raise RuntimeError("unexpected reply")
    words = [result[2 + 64 * i: 2 + 64 * (i + 1)].lower() for i in range(8)]
    return {
        "pictureHash": words[0],
        "metadataHash": words[1],
        "blockTimestamp": int(words[5], 16),
        "version": int(words[6], 16),
        "exists": int(words[7], 16) == 1,
    }


def utc(seconds):
    return datetime.fromtimestamp(seconds, timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")


def check_proof(p, rpc, registry):
    if not p["eventId"]:
        return {"verdict": "UNCHECKED", "reason": "no_event_id"}
    if not p["metadataHash"]:
        return {"verdict": "UNCHECKED", "reason": "record_missing"}
    if not p["pictureHash"]:
        return {"verdict": "UNCHECKED", "reason": "picture_unreadable"}
    try:
        chain = get_proof(rpc, registry, p["eventId"])
    except Exception as e:  # network, HTTP, RPC error: could not look, never "not recorded"
        return {"verdict": "LOOKUP_FAILED", "reason": "lookup_failed", "error": str(e)[:120]}
    if not chain["exists"]:
        return {"verdict": "NOT_FOUND", "reason": None}
    zero = "0" * 64
    if chain["pictureHash"] == zero or chain["metadataHash"] == zero:
        return {"verdict": "UNCHECKED", "reason": "chain_record_incomplete"}
    photo_ok = chain["pictureHash"] == p["pictureHash"]
    record_ok = chain["metadataHash"] == p["metadataHash"]
    verdict = ("VERIFIED" if photo_ok and record_ok else "TAMPERED_BOTH" if not photo_ok and not record_ok
               else "TAMPERED_DETAILS" if photo_ok else "TAMPERED_PHOTO")
    signed_offline = False
    spoofed = False
    try:
        record = json.loads(p["metaCanon"])
        cap = record.get("capture") or {}
        signed_offline = bool(cap.get("isOffline"))
        # Only a positive signed flag counts; no location or no flag says nothing.
        spoofed = (record.get("location") or {}).get("isMocked") is True
    except (ValueError, AttributeError):
        pass
    if chain["version"] == VERSION_NONCE_VERIFIED:
        network = "online"
    elif chain["blockTimestamp"] >= VERSION_BYTE_SINCE:
        network = "offline"
    else:
        network = "unconfirmed"
    if signed_offline:
        network = "offline"
    # The record's flags count only when its hash matched the chain.
    return {"verdict": verdict, "reason": None, "anchored": chain["blockTimestamp"], "network": network,
            "spoofedLocation": spoofed and record_ok}


# ---------------------------------------------------------------------------

def printed_text(pdf, poppler):
    exe = os.path.join(poppler, "pdftotext") if poppler else "pdftotext"
    out = subprocess.run([exe, "-raw", "-enc", "UTF-8", pdf, "-"], check=True, capture_output=True).stdout
    return out.decode("utf-8", errors="replace")


def verify(pdf, rpc, registry, poppler):
    buf = open(pdf, "rb").read()
    try:
        objects, trailer, file_problem = read_file(buf)
        doc = Doc(objects, trailer)
        root, pages = doc.pages()
        kind, data = embedded_record(doc, root)
    except Unreadable:
        raise NotACertificate("This PDF could not be opened as a Captilo certificate.")

    if kind == "SINGLE_PROOF":
        if not isinstance(data, dict) or data.get("v") != 2 or not isinstance(data.get("m"), dict):
            raise NotACertificate("An older certificate format that is no longer supported.")
        listed = [data["m"]]
    else:
        proofs = data.get("proofs") if isinstance(data, dict) else None
        if data.get("v") != 3 or data.get("type") != "multi-proof" or not isinstance(proofs, list) or not proofs:
            raise NotACertificate("An older certificate format that is no longer supported.")
        if len(proofs) > MAX_PROOFS:
            raise NotACertificate(f"A certificate holds at most {MAX_PROOFS} proofs.")
        listed = [p if isinstance(p, dict) else {} for p in proofs]

    reads = []
    for i, (page, inh) in enumerate(pages):
        try:
            reads.append(check_page(doc, page, inh, i + 1))
        except Unreadable:
            reads.append((None, ("TAMPERED_CERTIFICATE", "content_unreadable")))
    problems = [p for _, p in reads if p]
    structure = (file_problem or document_problem(doc, root)
                 or next((p for p in problems if p[0] == "TAMPERED_CERTIFICATE"), None)
                 or (problems[0] if problems else None))

    proofs = []
    for i, p in enumerate(listed):
        canon = p.get("metaCanon") if isinstance(p.get("metaCanon"), str) and p.get("metaCanon") else None
        eid = p.get("blockchainEventId")
        photo = reads[i][0] if i < len(reads) else None
        proofs.append({
            "eventId": eid.lower() if isinstance(eid, str) and UUID.match(eid) else None,
            "metaCanon": canon,
            "pictureHash": hashlib.sha256(photo).hexdigest() if photo else None,
            "metadataHash": hashlib.sha256(canon.encode("utf-8")).hexdigest() if canon else None,
        })

    has_record = any(p["metaCanon"] for p in proofs)
    s = structure if has_record else None
    problem = None
    if len(pages) != len(proofs):
        problem = ("TAMPERED_CERTIFICATE", "page_count_mismatch")
    elif s and s[0] == "TAMPERED_CERTIFICATE":
        problem = ("TAMPERED_CERTIFICATE", "page_structure")
    elif s:
        problem = ("UNCHECKED", "edited_after_issue")
    elif has_record:
        printed = re.sub(r"\s+", "", printed_text(pdf, poppler))
        if len(printed) > 50 and not all(re.sub(r"\s+", "", p["metaCanon"]) in printed for p in proofs if p["metaCanon"]):
            problem = ("TAMPERED_CERTIFICATE", "printed_record_mismatch")
    if problem:
        return {"verdict": problem[0], "reason": problem[1], "proofCount": len(proofs), "verifiedCount": 0,
                "multi": kind == "MULTI_PROOF", "perProof": [], "pages": len(pages)}

    per = []
    for i, p in enumerate(proofs):
        r = check_proof(p, rpc, registry)
        r.update(page=i + 1, eventId=p["eventId"], pictureHash=p["pictureHash"], metadataHash=p["metadataHash"])
        per.append(r)
    count = sum(1 for r in per if r["verdict"] == "VERIFIED")
    if kind == "SINGLE_PROOF":
        verdict, reason = per[0]["verdict"], per[0]["reason"]
    else:
        verdict = "MULTI_VERIFIED" if count == len(per) else "MULTI_PARTIAL" if count else "MULTI_NOT_VERIFIED"
        reason = None
    return {"verdict": verdict, "reason": reason, "proofCount": len(per), "verifiedCount": count,
            "multi": kind == "MULTI_PROOF", "perProof": per, "pages": len(pages)}


EXPLAIN = {
    "page_count_mismatch": "pages were added or removed",
    "page_structure": "a page draws something the Captilo app never draws, so what it shows is not the recorded picture",
    "file_structure": "the file holds two readings (objects not where its cross-reference says)",
    "edited_after_issue": "the file was edited after it was issued (for example, saved again with comments)",
    "printed_record_mismatch": "the record printed on the page is not the embedded record",
    "no_event_id": "no valid Event ID",
    "record_missing": "no embedded record (older certificate formats are not supported)",
    "picture_unreadable": "no JPEG photo on the page",
    "chain_record_incomplete": "the chain record is incomplete",
    "lookup_failed": "the blockchain could not be read; try again",
}


def main():
    parser = argparse.ArgumentParser(description="Check a Captilo certificate without Captilo.")
    parser.add_argument("pdf")
    parser.add_argument("--rpc", default=DEFAULT_RPC, help="any Sonic mainnet RPC endpoint")
    parser.add_argument("--poppler", default="", help="folder with pdftotext, if not on PATH")
    parser.add_argument("--registry", default=REGISTRY, help="registry contract, if the certificate's transaction went elsewhere")
    parser.add_argument("--json", action="store_true", help="print the result as JSON")
    args = parser.parse_args()

    try:
        chain_id = int(rpc_call(args.rpc, "eth_chainId", []), 16)
    except Exception as e:
        print(f"Could not reach the Sonic node {args.rpc} ({e}). Nothing was checked; try again.")
        sys.exit(3)
    if chain_id != SONIC_CHAIN_ID:
        sys.exit(f"The RPC endpoint is chain {chain_id}, not Sonic mainnet ({SONIC_CHAIN_ID}).")

    try:
        result = verify(args.pdf, args.rpc, args.registry, args.poppler)
    except NotACertificate as e:
        if args.json:
            print(json.dumps({"verdict": "NOT_A_CERTIFICATE", "reason": str(e)}))
        else:
            print(f"NOT CHECKED: {e}")
        sys.exit(2)

    if args.json:
        print(json.dumps(result))
    else:
        print(f"Registry {args.registry} on Sonic (chain {chain_id}) via {args.rpc}\n")
        if not result["perProof"]:
            print(f"{result['verdict']}: {EXPLAIN.get(result['reason'], result['reason'])}. Nothing was looked up.")
        for r in result["perProof"]:
            print(f"Page {r['page']}: event {r['eventId'] or '-'}")
            print(f"  picture SHA-256 (the photo the page draws)  {r['pictureHash'] or '-'}")
            print(f"  record SHA-256 (embedded metaCanon)        {r['metadataHash'] or '-'}")
            line = f"  {r['verdict']}"
            if r.get("reason"):
                line += f": {EXPLAIN.get(r['reason'], r['reason'])}"
            print(line)
            if "anchored" in r:
                print(f"  anchored at {utc(r['anchored'])}; capture {r['network']}"
                      + (" (device-clock time, capture grade at most C)" if r["network"] == "offline" else "")
                      + (" (as recorded by the device; not confirmed by the server)" if r["network"] == "unconfirmed" else ""))
            if r.get("spoofedLocation"):
                print("  SPOOF LOCATION DETECTED: the recorded location came from a location-simulation app; the place is not confirmed")
        if result["perProof"]:
            print(f"\n{result['verdict']}: {result['verifiedCount']} of {result['proofCount']} proof(s) verified.")
    if result["verdict"] in ("VERIFIED", "MULTI_VERIFIED"):
        sys.exit(0)
    if any(r["verdict"] == "LOOKUP_FAILED" for r in result["perProof"]):
        sys.exit(3)
    sys.exit(1)


if __name__ == "__main__":
    main()
