#!/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).

  A Validag Verification Statement (a PDF with attached files, one of them
  validag-statement.json) is checked by its own rules first (STATEMENT_SPEC v1): the
  file as above, the signed record against Validag's published statement key, the
  statement pages and their binding, every attached annex byte for byte. Then each
  annex is checked on its own as a Captilo file.

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 base64
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.request
import zlib
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP

# 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", "captilo.report"}
REPORT_FIELD, REPORT_START, REPORT_END = "captilo.report", "CAPTILO_R1_START", "CAPTILO_R1_END"
# The app's template names (src/reports/templates.ts, English); an unknown id reads "Report".
REPORT_TEMPLATE_NAMES = {"job-completion": "Job completion", "installation": "Installation report",
                         "rental-handover": "Rental handover", "boundary-walk": "Boundary walk",
                         "field-check": "Field check"}
REPORT_RESOURCE_KEYS = {"Font", "XObject", "ExtGState", "ColorSpace", "ProcSet"}
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
            # CRLF or LF only (PDF 7.3.8.1): after a lone CR the first data byte is
            # ambiguous and readers could hash different bytes (2026-10-27).
            if buf[r.p:r.p + 2] == b"\r\n":
                r.p += 2
            elif buf[r.p:r.p + 1] == b"\n":
                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" and t != "EmbeddedFile":
                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", "Rotate"):
                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 page_content(doc, page):
    """The page's decoded content streams, joined by newline (bytes)."""
    contents = doc.get(page.get("Contents"))
    streams = [doc.get(x) for x in contents] if isinstance(contents, list) else ([contents] if contents is not None else [])
    parts, total = [], 0
    for st in streams:
        if not isinstance(st, Stream):
            raise Unreadable("content")
        f = st.d.get("Filter")
        if f is None:
            data = st.raw
        elif f == "FlateDecode":
            data = inflate(st.raw, [0], MAX_CONTENT)
        else:
            raise Unreadable("content filter")
        total += len(data)
        if total > MAX_CONTENT:
            raise Unreadable("content too large")
        parts.append(data)
    return b"\n".join(parts)


LOGO_KEYS = {"Type", "Subtype", "Width", "Height", "ColorSpace", "BitsPerComponent", "Filter", "Length"}
LOGO_MAX_PX, LOGO_MAX_BYTES, LOGO_MAX_W, LOGO_MAX_H, LOGO_TOP_GAP, LOGO_BAND = 1024, 256 * 1024, 140, 56, 60, 160


def _logo_bad(st):
    """The sender's logo (3.1 rule 2a), judged from its dictionary alone, never decoded:
    a direct-length DCT JPEG, DeviceRGB or DeviceGray, 8 bits, 1..1024 px a side, at most
    256 KB stored, no other key. Values are read as written: a reference is refused."""
    if not isinstance(st, Stream):
        return True
    d = st.d
    if any(k not in LOGO_KEYS for k in d):
        return True
    if "Type" in d and not (isinstance(d["Type"], Name) and d["Type"] == "XObject"):
        return True
    if not (isinstance(d.get("Subtype"), Name) and d["Subtype"] == "Image"):
        return True
    if not (isinstance(d.get("Filter"), Name) and d["Filter"] == "DCTDecode"):
        return True
    if not (isinstance(d.get("ColorSpace"), Name) and d["ColorSpace"] in ("DeviceRGB", "DeviceGray")):
        return True
    is_int = lambda v: isinstance(v, int) and not isinstance(v, bool)
    if not (is_int(d.get("BitsPerComponent")) and d["BitsPerComponent"] == 8):
        return True
    for k in ("Width", "Height"):
        if not (is_int(d.get(k)) and 1 <= d[k] <= LOGO_MAX_PX):
            return True
    if not is_int(d.get("Length")):
        return True
    return len(st.raw) > LOGO_MAX_BYTES


def check_report_page(doc, page, inh, n):
    """A REPORT page (VERIFICATION_RULES.md 3.1): the author's statements in front of the
    proof pages, never verified. No XObject entries (no images, no forms) except the
    sender's logo on the first report page (rule 2a: drawn first, as
    `q a 0 0 d e f cm /Name Do Q`, inside the letterhead band of its own MediaBox at 0 0),
    no inline images, standard Type 1 fonts only (no embedded font program), the
    proof-page forbidden operators, links without an appearance only. Returns a problem
    or None."""
    if "AA" in page:
        return ("TAMPERED_CERTIFICATE", "script")
    resources = doc.get(inh.get("Resources")) or {}
    if not isinstance(resources, dict) or any(k not in REPORT_RESOURCE_KEYS for k in resources):
        return ("TAMPERED_CERTIFICATE", "report_page_resources")
    logo = None
    if "XObject" in resources:
        xo = doc.get(resources.get("XObject"))
        if not isinstance(xo, dict):
            return ("TAMPERED_CERTIFICATE", "report_page_xobject")
        if len(xo) > 0:
            if n != 1 or len(xo) != 1:
                return ("TAMPERED_CERTIFICATE", "report_page_xobject")
            logo = next(iter(xo))
            if _logo_bad(doc.get(xo[logo])):
                return ("TAMPERED_CERTIFICATE", "report_logo")
    fonts = doc.get(resources.get("Font"))
    if fonts is not None:
        if not isinstance(fonts, dict):
            return ("TAMPERED_CERTIFICATE", "report_page_resources")
        for ref in fonts.values():
            font = doc.get(ref)
            if not isinstance(font, dict) or font.get("Subtype") != "Type1" or "FontDescriptor" in font:
                return ("TAMPERED_CERTIFICATE", "report_page_font")
    try:
        toks = tokenize(page_content(doc, page).decode("latin-1"))
    except (Unreadable, IndexError):
        return ("TAMPERED_CERTIFICATE", "content_unreadable")
    if logo is not None:
        h = toks[:11]
        nums = [float(t) if NUM.match(t) else None for t in h[1:7]] if len(h) == 11 else [None]
        if (len(h) != 11 or h[0] != "q" or h[7] != "cm" or h[8] != "/" + logo or h[9] != "Do" or h[10] != "Q"
                or any(x is None for x in nums)):
            return ("TAMPERED_CERTIFICATE", "report_logo_placement")
        a, b, c, d, e, f = nums
        mb = doc.get(inh.get("MediaBox"))
        if not (isinstance(mb, list) and len(mb) == 4 and all(isinstance(x, (int, float)) for x in mb)):
            return ("TAMPERED_CERTIFICATE", "report_logo_placement")
        width, height = mb[2] - mb[0], mb[3] - mb[1]
        if (b != 0 or c != 0 or not a > 0 or not d > 0 or mb[0] != 0 or mb[1] != 0
                or a > LOGO_MAX_W or d > LOGO_MAX_H or e < 0 or e + a > width / 2
                or f + d > height - LOGO_TOP_GAP or f < height - LOGO_BAND):
            return ("TAMPERED_CERTIFICATE", "report_logo_placement")
        toks = toks[11:]
    for t in toks:
        if t in FORBIDDEN_OPS or t == "Do":
            return ("TAMPERED_CERTIFICATE", "report_page_operator_" + t)
    for ref in doc.get(page.get("Annots")) or []:
        a = doc.get(ref)
        if not isinstance(a, dict):
            return ("UNCHECKED", "edited_after_issue")
        if a.get("Subtype") == "Link" and "AP" not in a:
            continue
        return ("UNCHECKED", "edited_after_issue")
    return None


def _pdf_name(v):
    return "/" + v if isinstance(v, Name) else ("" if v is None else "?")


def _f2(x):
    """Two decimals exactly as JavaScript's Number.prototype.toFixed(2), which the app
    and the website use: the exact binary value of |x| rounded half up, then the sign
    (so -0.001 prints -0.00), and -0 prints 0.00. Python's own format rounds a tie to
    even, so 519.125 would print 519.12 here and 519.13 there, and a genuine report
    would read altered."""
    x = float(x)
    if x == 0:
        return "0.00"
    q = Decimal(abs(x)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
    return ("-" if x < 0 else "") + format(q, "f")


def _box(v):
    if not (isinstance(v, list) and len(v) == 4 and all(isinstance(x, (int, float)) for x in v)):
        raise Unreadable("box")
    return ",".join(_f2(x) for x in (v[0], v[1], v[2] - v[0], v[3] - v[1]))


def report_pages_hash(doc, pages):
    """What the report pages draw, as one SHA-256 (the same recipe as the app and the
    website): per page, sha256(decoded content) | fonts name=Subtype/BaseFont/Encoding
    sorted | MediaBox | CropBox (x,y,w,h, two decimals) | rotation | links (subtype and
    Rect, two decimals), and a 7th part, images, ONLY when the page holds XObjects (the
    logo, rule 2a), so every report signed before the logo keeps its hash. Lines joined
    by newline. None when a page cannot be read."""
    try:
        lines = []
        for line, (page, inh) in zip(_page_binding_lines(doc, pages), pages):
            images = _images_part(doc, inh)
            lines.append(line + "|" + images if images else line)
        return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
    except (Unreadable, TypeError, ValueError):
        return None


def _images_part(doc, inh):
    """Part 7: NAME=SHA-256 of each XObject's stored stream bytes, NAME without the
    slash, sorted by UTF-16 code unit, joined by commas; "" without XObjects."""
    resources = doc.get(inh.get("Resources")) or {}
    xo = doc.get(resources.get("XObject")) if isinstance(resources, dict) and "XObject" in resources else {}
    parts = []
    for name in sorted(xo or {}, key=lambda n: n.encode("utf-16-be", "surrogatepass")):
        st = doc.get(xo[name])
        if not isinstance(st, Stream):
            raise Unreadable("image")
        parts.append(f"{name}={hashlib.sha256(st.raw).hexdigest()}")
    return ",".join(parts)


def _page_binding_lines(doc, pages):
    """One line per page with the six parts above; statement pages add a seventh
    (statement_pages_hash). Raises when a page cannot be read."""
    lines = []
    for page, inh in pages:
        content = hashlib.sha256(page_content(doc, page)).hexdigest()
        resources = doc.get(inh.get("Resources")) or {}
        fonts = []
        fd = doc.get(resources.get("Font")) if isinstance(resources, dict) else None
        if isinstance(fd, dict):
            for name, ref in fd.items():
                f = doc.get(ref)
                if not isinstance(f, dict):
                    raise Unreadable("font")
                fonts.append(f"{name}={_pdf_name(f.get('Subtype'))}/{_pdf_name(f.get('BaseFont'))}/{_pdf_name(f.get('Encoding'))}")
        fonts.sort()
        media = doc.get(inh.get("MediaBox"))
        crop = doc.get(inh.get("CropBox")) if "CropBox" in inh else media
        rot = doc.get(inh.get("Rotate")) if "Rotate" in inh else 0
        links = []
        for ref in doc.get(page.get("Annots")) or []:
            a = doc.get(ref)
            if not isinstance(a, dict):
                raise Unreadable("annot")
            r = doc.get(a.get("Rect"))
            nums = ",".join(_f2(x) for x in r) if isinstance(r, list) else ""
            links.append(f"{_pdf_name(a.get('Subtype'))}:{nums}")
        lines.append("|".join([content, ",".join(fonts), _box(media), _box(crop), str(int(rot)), ";".join(links)]))
    return lines


# secp256k1, verify only (Python stdlib, no library to trust)
_P = 2 ** 256 - 2 ** 32 - 977
_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
_G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
      0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)


def _add(a, b):
    if a is None:
        return b
    if b is None:
        return a
    if a[0] == b[0] and (a[1] + b[1]) % _P == 0:
        return None
    if a == b:
        lam = 3 * a[0] * a[0] * pow(2 * a[1], -1, _P) % _P
    else:
        lam = (b[1] - a[1]) * pow(b[0] - a[0], -1, _P) % _P
    x = (lam * lam - a[0] - b[0]) % _P
    return (x, (lam * (a[0] - x) - a[1]) % _P)


def _mul(k, pt):
    out = None
    while k:
        if k & 1:
            out = _add(out, pt)
        pt = _add(pt, pt)
        k >>= 1
    return out


def secp256k1_verify(message, sig_hex, key_hex):
    """Compact r||s over SHA-256(message), key 04+X+Y, low-s only (as the app's library)."""
    try:
        r, s_ = int(sig_hex[:64], 16), int(sig_hex[64:], 16)
        q = (int(key_hex[2:66], 16), int(key_hex[66:], 16))
    except ValueError:
        return False
    if not (1 <= r < _N and 1 <= s_ <= _N // 2):
        return False
    if (q[1] * q[1] - q[0] ** 3 - 7) % _P:
        return False
    z = int.from_bytes(hashlib.sha256(message).digest(), "big")
    w = pow(s_, -1, _N)
    x = _add(_mul(z * w % _N, _G), _mul(r * w % _N, q))
    return x is not None and x[0] % _N == r


def check_report_part(embedded, page_count, page_problem, pages_hash, proofs):
    """The report part (VERIFICATION_RULES.md 3.1). Returns (info, None) or (None, code)."""
    rec_text, sig, key = embedded.get("record"), embedded.get("signature"), embedded.get("publicKey")
    if not all(isinstance(x, str) for x in (rec_text, sig, key)):
        return None, "record_shape"
    try:
        rec = json.loads(rec_text)
    except ValueError:
        return None, "record_unreadable"
    if not isinstance(rec, dict) or rec.get("format") != "captilo-report-v1":
        return None, "record_format"
    k = page_count - len(proofs)
    if not isinstance(rec.get("reportPages"), int) or k < 1 or k != rec.get("reportPages"):
        return None, "page_count"
    if page_problem:
        return None, "report_page"
    if not pages_hash or not isinstance(rec.get("reportPagesHash"), str) or rec["reportPagesHash"].lower() != pages_hash:
        return None, "pages_changed"
    sig, key = sig.lower(), key.lower()
    if not re.fullmatch(r"[0-9a-f]{128}", sig) or not re.fullmatch(r"04[0-9a-f]{128}", key):
        return None, "signature_shape"
    if not secp256k1_verify(rec_text.encode("utf-8"), sig, key):
        return None, "signature"
    for p in proofs:
        try:
            pkh = json.loads(p["metaCanon"]).get("publicKeyHash") if p["metaCanon"] else None
        except ValueError:
            pkh = None
        if not isinstance(pkh, str) or len(pkh) < 32 or key[:32] != pkh.lower()[:32]:
            return None, "other_key"
    listed = [ph for ph in (rec.get("photos") if isinstance(rec.get("photos"), list) else []) if isinstance(ph, dict) and ph.get("part") == rec.get("part")]
    if len(listed) != len(proofs):
        return None, "photo_list"
    for ph, p in zip(listed, proofs):
        if not p["pictureHash"] or not isinstance(ph.get("pictureHash"), str) or ph["pictureHash"].lower() != p["pictureHash"]:
            return None, "photo_list"
    fields = {i: v for i, v in (rec.get("fields") or {}).items() if isinstance(v, str)} if isinstance(rec.get("fields"), dict) else {}
    signers = [v["name"] for v in (rec.get("signatures") or {}).values() if isinstance(v, dict) and isinstance(v.get("name"), str)] if isinstance(rec.get("signatures"), dict) else []
    # The sender as signed (2026-10-27): the author's entry, never a checked identity.
    def s120(v):
        return v.strip()[:120] if isinstance(v, str) else ""
    sd = rec.get("sender") if isinstance(rec.get("sender"), dict) else None
    sender = None
    if sd and s120(sd.get("name")):
        sender = [x for x in (
            s120(sd.get("name")), s120(sd.get("contact")), s120(sd.get("street")),
            ", ".join(x for x in (s120(sd.get("city")), s120(sd.get("country"))) if x),
            " · ".join(x for x in (s120(sd.get("phone")), s120(sd.get("email")), s120(sd.get("web"))) if x),
        ) if x]
    template = rec.get("template") if isinstance(rec.get("template"), dict) else {}
    return {"pages": k, "reportId": rec.get("reportId", ""), "part": rec.get("part", 1), "parts": rec.get("parts", 1),
            "templateId": template.get("id", "") if isinstance(template.get("id"), str) else "",
            "fields": fields, "signers": signers, "sender": sender}, 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 form_fields(doc, root):
    """Every form field's text value, by full name."""
    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)
    return fields


def report_record(fields):
    """The signed report record (report certificates), or None when absent."""
    text = fields.get(REPORT_FIELD)
    if text is None:
        return None
    a = text.find(REPORT_START)
    b = text.find(REPORT_END, a + len(REPORT_START)) if a >= 0 else -1
    try:
        d = json.loads(text[a + len(REPORT_START):b]) if b >= 0 else None
    except ValueError:
        d = None
    if not isinstance(d, dict) or d.get("v") != 1:
        raise NotACertificate("The report's embedded record could not be read.")
    return d


def embedded_record(doc, root, fields=None):
    """The Captilo record from its form field only (never a search of the file)."""
    if fields is None:
        fields = form_fields(doc, root)
    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.")


# ---------------------------------------------------------------------------
# Validag Verification Statements (STATEMENT_SPEC v1; VERIFICATION_RULES.md 3.2, draft)
#
# A statement is a separate document from Validag Origin: k pages (analysis, maps,
# point table, decision), the Captilo field-check reports and certificates it rests
# on as attached files, and a signed record (attached file validag-statement.json)
# binding the pages and every annex by hash. The statement's verdict comes first;
# each annex is then checked on its own, as any Captilo file. Own code, shared rules
# and fixtures with Origin's reader; never shared code.
# ---------------------------------------------------------------------------

STATEMENT_FILE = "validag-statement.json"
STATEMENT_FORMAT = "validag-statement-v1"
# The published key list (https://validag.com/.well-known/validag-statement-keys.json)
# copied into this script: the only source of keys this script trusts. Empty until
# Validag publishes its statement key; --keys reads another list (for test fixtures).
STATEMENT_KEYS = []
MAX_ANNEX = 50 << 20
MAX_ANNEXES = 25
STANDARD_FONTS = {"Times-Roman", "Times-Bold", "Times-Italic", "Times-BoldItalic", "Helvetica", "Helvetica-Bold",
                  "Helvetica-Oblique", "Helvetica-BoldOblique", "Courier", "Courier-Bold", "Courier-Oblique",
                  "Courier-BoldOblique", "Symbol", "ZapfDingbats"}
HEX64 = re.compile(r"^[0-9a-f]{64}$")
UUID4 = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
UTC_TIME = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
MAX_SAFE = 2 ** 53 - 1


class StatementProblem(Exception):
    def __init__(self, verdict, reason):
        super().__init__(reason)
        self.verdict, self.reason = verdict, reason


def _js_string(s):
    """A string exactly as JavaScript's JSON.stringify writes it (RFC 8785 3.2.2.2)."""
    out = ['"']
    for ch in s:
        o = ord(ch)
        if ch == '"':
            out.append('\\"')
        elif ch == "\\":
            out.append("\\\\")
        elif ch in "\b\f\n\r\t":
            out.append({"\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}[ch])
        elif o < 0x20 or 0xD800 <= o <= 0xDFFF:
            out.append("\\u%04x" % o)
        else:
            out.append(ch)
    out.append('"')
    return "".join(out)


def canonical_json(v):
    """RFC 8785 for what a statement record may hold: no floats, integers within the
    exactly representable range. Keys sorted by UTF-16 code units."""
    if v is None:
        return "null"
    if v is True:
        return "true"
    if v is False:
        return "false"
    if isinstance(v, int):
        if abs(v) > MAX_SAFE:
            raise ValueError("integer out of range")
        return str(v)
    if isinstance(v, str):
        return _js_string(v)
    if isinstance(v, list):
        return "[" + ",".join(canonical_json(x) for x in v) + "]"
    if isinstance(v, dict):
        keys = sorted(v, key=lambda k: k.encode("utf-16-be", "surrogatepass"))
        return "{" + ",".join(_js_string(k) + ":" + canonical_json(v[k]) for k in keys) + "}"
    raise ValueError("not allowed in a record")


def _strict_json(text):
    """json.loads that refuses duplicate keys and any non-integer number."""
    def pairs(items):
        d = {}
        for k, val in items:
            if k in d:
                raise ValueError("duplicate key")
            d[k] = val
        return d

    def no_float(_):
        raise ValueError("float")

    return json.loads(text, object_pairs_hook=pairs, parse_float=no_float,
                      parse_constant=no_float)


def attached_files(doc, root):
    """The catalog's attached files as {name: bytes}, or None when the file has none.
    STATEMENT_SPEC 4: /Names holds only /EmbeddedFiles; its name tree (/Names or /Kids,
    depth at most 4, no name twice); each value a file specification whose /EF /F is a
    stream (direct /Length, FlateDecode or no filter, no /DecodeParms, at most 50 MB)."""
    bad = StatementProblem("TAMPERED_CERTIFICATE", "file_structure")
    names = doc.get(root.get("Names"))
    if names is None:
        return None
    if not isinstance(names, dict) or set(names) != {"EmbeddedFiles"}:
        raise bad
    out = {}

    def visit(node, depth):
        if depth > 4 or not isinstance(node, dict) or ("Kids" in node) == ("Names" in node):
            raise bad
        if "Kids" in node:
            kids = doc.get(node["Kids"])
            if not isinstance(kids, list):
                raise bad
            for kid in kids:
                visit(doc.get(kid), depth + 1)
            return
        arr = doc.get(node["Names"])
        if not isinstance(arr, list) or len(arr) % 2:
            raise bad
        for i in range(0, len(arr), 2):
            key = doc.get(arr[i])
            if not isinstance(key, bytes):
                raise bad
            name = text_of(key)
            spec = doc.get(arr[i + 1])
            ef = doc.get(spec.get("EF")) if isinstance(spec, dict) else None
            st = doc.get(ef.get("F")) if isinstance(ef, dict) else None
            if name in out or not isinstance(st, Stream) or "DecodeParms" in st.d or len(st.raw) > MAX_ANNEX:
                raise bad
            f = st.d.get("Filter")
            if f is None:
                data = st.raw
            elif f == "FlateDecode":
                d = zlib.decompressobj()
                try:
                    data = d.decompress(st.raw, MAX_ANNEX + 1)
                except zlib.error:
                    raise bad
                if len(data) > MAX_ANNEX:
                    raise bad
            else:
                raise bad
            out[name] = data
            if len(out) > MAX_ANNEXES + 1:
                raise bad

    visit(doc.get(names["EmbeddedFiles"]), 0)
    return out


def check_statement_page(doc, page, inh):
    """A statement page (STATEMENT_SPEC 5): text, vector graphics and image XObjects
    only (no SMask/Mask, no forms), the 14 standard Type 1 fonts without an embedded
    program, the forbidden operators of a Captilo page, Do naming only an image of
    the page, links without an appearance only. Returns a problem or None."""
    tampered = ("TAMPERED_CERTIFICATE", "statement_pages")
    if "AA" in page:
        return tampered
    resources = doc.get(inh.get("Resources")) or {}
    if not isinstance(resources, dict) or any(k not in REPORT_RESOURCE_KEYS for k in resources):
        return tampered
    images = set()
    xo = doc.get(resources.get("XObject")) if "XObject" in resources else {}
    if not isinstance(xo, dict):
        return tampered
    for name, ref in xo.items():
        st = doc.get(ref)
        if (not isinstance(st, Stream) or st.d.get("Subtype") != "Image" or "SMask" in st.d or "Mask" in st.d
                or st.d.get("Filter") not in (None, "FlateDecode", "DCTDecode")):
            return tampered
        images.add(name)
    fonts = doc.get(resources.get("Font")) if "Font" in resources else {}
    if not isinstance(fonts, dict):
        return tampered
    for ref in fonts.values():
        font = doc.get(ref)
        if (not isinstance(font, dict) or font.get("Subtype") != "Type1" or "FontDescriptor" in font
                or font.get("BaseFont") not in STANDARD_FONTS):
            return tampered
    try:
        toks = tokenize(page_content(doc, page).decode("latin-1"))
    except (Unreadable, IndexError):
        return tampered
    for i, t in enumerate(toks):
        if t in FORBIDDEN_OPS:
            return tampered
        if t == "Do":
            prev = toks[i - 1] if i else ""
            if not prev.startswith("/") or re.sub(r"#([0-9a-fA-F]{2})", lambda m: chr(int(m.group(1), 16)), prev[1:]) not in images:
                return tampered
    for ref in doc.get(page.get("Annots")) or []:
        a = doc.get(ref)
        if not (isinstance(a, dict) and a.get("Subtype") == "Link" and "AP" not in a):
            return ("UNCHECKED", "edited_after_issue")
    return None


def statement_pages_hash(doc, pages):
    """The statement pages' binding: the six parts of the report-page line (3.1, byte
    for byte) plus part 7, images: NAME=SHA-256 of the stored stream bytes, sorted by
    UTF-16 code unit, joined by commas. None when a page cannot be read."""
    try:
        # Statement lines ALWAYS have seven parts (part 7 "" without images); report
        # lines have seven only with XObjects. Different on purpose: keep both.
        lines = [line + "|" + _images_part(doc, inh) for line, (page, inh) in zip(_page_binding_lines(doc, pages), pages)]
        return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
    except (Unreadable, TypeError, ValueError):
        return None


def _shape(rec):
    """STATEMENT_SPEC 3: exactly the fields, with their types. True when it holds."""
    s = lambda v: isinstance(v, str)
    i = lambda v: isinstance(v, int) and not isinstance(v, bool)
    exact = lambda d, keys: isinstance(d, dict) and set(d) == set(keys)
    if not exact(rec, ["format", "statementId", "version", "supersedes", "issuedAt", "plot", "analysis", "fieldCheck",
                       "annexes", "decision", "disclosure", "limits", "statementPages", "statementPagesHash"]):
        return False
    if not (s(rec["statementId"]) and UUID4.match(rec["statementId"]) and i(rec["version"]) and rec["version"] >= 1):
        return False
    sup = rec["supersedes"]
    if rec["version"] == 1:
        if sup is not None:
            return False
    elif not (exact(sup, ["version", "sha256"]) and sup["version"] == rec["version"] - 1
              and s(sup["sha256"]) and HEX64.match(sup["sha256"])):
        return False
    if not (s(rec["issuedAt"]) and UTC_TIME.match(rec["issuedAt"])):
        return False
    p = rec["plot"]
    if not (exact(p, ["id", "label", "polygon", "polygonHash", "areaM2"]) and s(p["id"]) and s(p["label"])
            and s(p["polygon"]) and s(p["polygonHash"]) and HEX64.match(p["polygonHash"]) and i(p["areaM2"])):
        return False
    a = rec["analysis"]
    if not (exact(a, ["ruleVersion", "ranAt", "sources", "forest2020Bp", "convertedM2", "patches"]) and s(a["ruleVersion"])
            and s(a["ranAt"]) and UTC_TIME.match(a["ranAt"]) and isinstance(a["sources"], list) and all(s(x) for x in a["sources"])
            and i(a["forest2020Bp"]) and 0 <= a["forest2020Bp"] <= 10000 and i(a["convertedM2"]) and isinstance(a["patches"], list)):
        return False
    for pt in a["patches"]:
        if not (exact(pt, ["firstYear", "areaM2", "kind"]) and i(pt["firstYear"]) and i(pt["areaM2"])
                and pt["kind"] in ("conversion", "conversion_provisional", "other_canopy_loss")):
            return False
    fc = rec["fieldCheck"]
    if fc is not None:
        if not (exact(fc, ["pointsBlockHash", "orderId", "orderRecordHash", "points"]) and s(fc["pointsBlockHash"])
                and HEX64.match(fc["pointsBlockHash"]) and isinstance(fc["points"], list)):
            return False
        if (fc["orderId"] is None) != (fc["orderRecordHash"] is None):
            return False
        if fc["orderId"] is not None and not (s(fc["orderId"]) and UUID4.match(fc["orderId"])
                                              and s(fc["orderRecordHash"]) and HEX64.match(fc["orderRecordHash"])):
            return False
        for pt in fc["points"]:
            if not (exact(pt, ["id", "kind", "latE6", "lonE6", "verdict", "verifiedPhotos", "declared"]) and s(pt["id"])
                    and s(pt["kind"]) and i(pt["latE6"]) and i(pt["lonE6"]) and pt["verdict"] in ("CHECKED", "PARTIAL", "MISSING")
                    and i(pt["verifiedPhotos"]) and isinstance(pt["declared"], list)):
                return False
            for dc in pt["declared"]:
                if not (exact(dc, ["reportId", "status", "reason"]) and s(dc["reportId"])
                        and dc["status"] in ("captured", "unreachable", "not_visited") and (dc["reason"] is None or s(dc["reason"]))):
                    return False
    if not (isinstance(rec["annexes"], list) and len(rec["annexes"]) <= MAX_ANNEXES):
        return False
    seen = set()
    for an in rec["annexes"]:
        if not (exact(an, ["name", "sha256", "bytes", "kind", "reportId"]) and s(an["name"]) and an["name"] != STATEMENT_FILE
                and an["name"] not in seen and s(an["sha256"]) and HEX64.match(an["sha256"]) and i(an["bytes"]) and an["bytes"] >= 0
                and an["kind"] in ("captilo-field-check-report", "captilo-report", "captilo-certificate")
                and (an["reportId"] is None if an["kind"] == "captilo-certificate" else s(an["reportId"]))):
            return False
        seen.add(an["name"])
    d = rec["decision"]
    if not (exact(d, ["status", "note", "decidedAt", "reviewer"]) and d["status"] in ("NO_CONVERSION_DETECTED", "REVIEW", "FLAGGED", "INSUFFICIENT_DATA")
            and s(d["note"]) and s(d["decidedAt"]) and UTC_TIME.match(d["decidedAt"])
            and exact(d["reviewer"], ["id", "qualificationRef"]) and s(d["reviewer"]["id"]) and s(d["reviewer"]["qualificationRef"])):
        return False
    if rec["disclosure"] != "validag-disclosure-v1" or not (isinstance(rec["limits"], list) and all(s(x) for x in rec["limits"])):
        return False
    return i(rec["statementPages"]) and rec["statementPages"] >= 1 and s(rec["statementPagesHash"]) and HEX64.match(rec["statementPagesHash"]) is not None


def _der(b, at):
    """One DER element at `at`: (tag, start of content, end of content)."""
    tag = b[at]
    n = b[at + 1]
    at += 2
    if n & 0x80:
        k = n & 0x7F
        n = int.from_bytes(b[at:at + k], "big")
        at += k
    return tag, at, at + n


def _der_children(b, start, end):
    out = []
    while start < end:
        t, s0, e0 = _der(b, start)
        out.append((t, s0, e0))
        start = e0
    return out


def timestamp_imprint(token):
    """The RFC 3161 token's messageImprint hash and genTime, read from its TSTInfo.
    Returns (hash bytes, genTime text) or None. This script does NOT check the TSA's
    signature or whether the TSA is qualified (an open item, STATEMENT_SPEC 2)."""
    try:
        b = token
        _, s, e = _der(b, 0)                                  # ContentInfo
        kids = _der_children(b, s, e)
        _, s, e = _der(b, kids[1][1])                         # [0] -> SignedData
        sd = _der_children(b, s, e)
        _, s, e = sd[2]                                       # encapContentInfo
        eci = _der_children(b, s, e)
        _, s, e = _der(b, eci[1][1])                          # [0] -> OCTET STRING TSTInfo
        _, s, e = _der(b, s)                                  # TSTInfo SEQUENCE
        tst = _der_children(b, s, e)
        _, s, e = tst[2]                                      # messageImprint
        mi = _der_children(b, s, e)
        digest = b[mi[1][1]:mi[1][2]]
        gen = b[tst[4][1]:tst[4][2]].decode("ascii", "replace")
        return digest, gen
    except (IndexError, ValueError):
        return None


def verify_statement(doc, root, pages, file_problem, keys):
    """STATEMENT_SPEC 6, first failing check wins. Returns a result dict; raises
    nothing for a statement. `keys` = the trusted key list's entries."""
    def result(verdict, reason, info=None, annexes=None, timestamp="none"):
        return {"kind": "statement", "verdict": verdict, "reason": reason, "info": info, "annexes": annexes or [],
                "timestamp": timestamp}

    # 1 the file
    if file_problem:
        return result(*file_problem)
    if document_problem(doc, root) or "AcroForm" in root:
        return result("TAMPERED_CERTIFICATE", "file_structure")
    try:
        files = attached_files(doc, root)
    except StatementProblem as p:
        return result(p.verdict, p.reason)
    # 1c an added comment on a page is an edit, not tampering (checked before the binding)
    for page, _ in pages:
        for ref in doc.get(page.get("Annots")) or []:
            a = doc.get(ref)
            if not (isinstance(a, dict) and a.get("Subtype") == "Link" and "AP" not in a):
                return result("UNCHECKED", "edited_after_issue")
    # 2 the record is there
    if STATEMENT_FILE not in files:
        return result("UNCHECKED", "no_statement_record")
    # 3 envelope and record
    try:
        env = _strict_json(files[STATEMENT_FILE].decode("utf-8"))
    except (UnicodeDecodeError, ValueError):
        return result("TAMPERED_CERTIFICATE", "statement_record")
    if not (isinstance(env, dict) and set(env) == {"v", "record", "signature", "keyId", "timestamp"} and env["v"] == 1
            and isinstance(env["record"], str) and isinstance(env["signature"], str) and re.fullmatch(r"[0-9a-f]{128}", env["signature"])
            and isinstance(env["keyId"], str)
            and (env["timestamp"] is None or (isinstance(env["timestamp"], dict) and set(env["timestamp"]) == {"kind", "token"}
                                              and env["timestamp"]["kind"] == "rfc3161" and isinstance(env["timestamp"]["token"], str)))):
        return result("TAMPERED_CERTIFICATE", "statement_record")
    record_text = env["record"]
    try:
        rec = _strict_json(record_text)
        canonical = canonical_json(rec) == record_text
    except ValueError:
        return result("TAMPERED_CERTIFICATE", "statement_record")
    if not canonical or not isinstance(rec, dict) or rec.get("format") != STATEMENT_FORMAT or not _shape(rec):
        return result("TAMPERED_CERTIFICATE", "statement_record")
    # 4 the key, from the trusted list only
    matches = [k for k in keys if isinstance(k, dict) and k.get("keyId") == env["keyId"]]
    if len(matches) != 1 or not re.fullmatch(r"04[0-9a-f]{128}", str(matches[0].get("publicKey"))):
        return result("UNCHECKED", "unknown_key")
    key = matches[0]
    if key.get("purpose") != "statement":
        return result("UNCHECKED", "wrong_purpose")
    if key.get("revokedAt") is not None:
        return result("UNCHECKED", "key_revoked")
    if rec["issuedAt"] < str(key.get("validFrom")) or (key.get("validTo") is not None and rec["issuedAt"] > key["validTo"]):
        return result("UNCHECKED", "key_not_valid_at_issue")
    # 5 the signature (low s only)
    if not secp256k1_verify(record_text.encode("utf-8"), env["signature"], key["publicKey"]):
        return result("TAMPERED_CERTIFICATE", "signature")
    # 6 the pages
    if len(pages) != rec["statementPages"]:
        return result("TAMPERED_CERTIFICATE", "statement_pages")
    for page, inh in pages:
        try:
            pr = check_statement_page(doc, page, inh)
        except Unreadable:
            pr = ("TAMPERED_CERTIFICATE", "statement_pages")
        if pr:
            return result(*pr)
    if statement_pages_hash(doc, pages) != rec["statementPagesHash"]:
        return result("TAMPERED_CERTIFICATE", "statement_pages")
    # 7 the annexes: exactly the listed files, byte for byte
    listed = {an["name"]: an for an in rec["annexes"]}
    attached = {n: b for n, b in files.items() if n != STATEMENT_FILE}
    if set(attached) != set(listed):
        return result("TAMPERED_CERTIFICATE", "annexes")
    for name, data in attached.items():
        an = listed[name]
        if len(data) != an["bytes"] or hashlib.sha256(data).hexdigest() != an["sha256"]:
            return result("TAMPERED_CERTIFICATE", "annexes")
    # beside the verdict: the timestamp, never deciding it
    ts = "none"
    if env["timestamp"] is not None:
        try:
            token = base64.b64decode(env["timestamp"]["token"], validate=True)
        except ValueError:
            token = b""
        read = timestamp_imprint(token) if token else None
        if read is None or read[0] != hashlib.sha256(record_text.encode("utf-8")).digest():
            ts = "invalid"
        else:
            g = read[1]
            when = f"{g[0:4]}-{g[4:6]}-{g[6:8]} {g[8:10]}:{g[10:12]}:{g[12:14]} UTC" if re.match(r"^\d{14}", g) else g
            ts = f"present, made {when} for this record; the TSA's signature is not checked by this script"
    info = {"issuer": key.get("organisation", ""), "statementId": rec["statementId"], "version": rec["version"],
            "supersedes": rec["supersedes"], "issuedAt": rec["issuedAt"], "plot": rec["plot"]["label"],
            "decision": rec["decision"]["status"], "note": rec["decision"]["note"], "fieldCheck": rec["fieldCheck"],
            "limits": rec["limits"]}
    return result("VERIFIED", None, info, [(an["name"], an["kind"], attached[an["name"]]) for an in rec["annexes"]], ts)


# ---------------------------------------------------------------------------
# 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()
        fields = form_fields(doc, root)
        kind, data = embedded_record(doc, root, fields)
        report = report_record(fields)
    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]

    # A report certificate: k report pages in front of the proof pages, judged with the
    # signed report record. Without a record there are no report pages.
    k = max(0, len(pages) - len(listed)) if report else 0
    report_problems = []
    for i, (page, inh) in enumerate(pages[:k]):
        try:
            pr = check_report_page(doc, page, inh, i + 1)
        except Unreadable:
            pr = ("TAMPERED_CERTIFICATE", "content_unreadable")
        if pr:
            report_problems.append(pr)
    reads = []
    for i, (page, inh) in enumerate(pages[k:]):
        try:
            reads.append(check_page(doc, page, inh, k + 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
    report_info = None
    if report is not None:
        if report_problems and report_problems[0][0] == "UNCHECKED":
            problem = ("UNCHECKED", "edited_after_issue")
        else:
            report_info, _code = check_report_part(report, len(pages), report_problems[0] if report_problems else None,
                                                   report_pages_hash(doc, pages[:k]), proofs)
            if report_info is None:
                problem = ("TAMPERED_CERTIFICATE", "report_part")
    if problem:
        pass
    elif report is None and 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), "report": report_info}


def read_statement(pdf, keys_file):
    """The statement result when the file carries attached files, else None (then it
    is handled as a Captilo certificate)."""
    buf = open(pdf, "rb").read()
    try:
        objects, trailer, file_problem = read_file(buf)
        doc = Doc(objects, trailer)
        root, pages = doc.pages()
    except (Unreadable, NotACertificate):
        return None
    names = doc.get(root.get("Names"))
    if not (isinstance(names, dict) and "EmbeddedFiles" in names):
        return None
    keys = STATEMENT_KEYS
    if keys_file:
        with open(keys_file, encoding="utf-8") as fh:
            keys = json.load(fh).get("keys", [])
    try:
        return verify_statement(doc, root, pages, file_problem, keys)
    except Unreadable:
        return {"kind": "statement", "verdict": "TAMPERED_CERTIFICATE", "reason": "file_structure", "info": None,
                "annexes": [], "timestamp": "none"}


STATEMENT_EXPLAIN = {
    "file_structure": "the file is not one the issuer wrote (objects, attached files, scripts or forms)",
    "edited_after_issue": "the file was edited after it was issued (saved again, or comments added)",
    "no_statement_record": "it carries attached files but no signed statement record",
    "statement_record": "the signed record is malformed or not in its canonical form",
    "unknown_key": "it was not signed with a key Validag published for statements",
    "wrong_purpose": "it was signed with a Validag key that is not for statements",
    "key_revoked": "the key that signed it has been revoked",
    "key_not_valid_at_issue": "it was signed outside the time its key was valid",
    "signature": "the record was changed after it was signed",
    "statement_pages": "the statement pages are not the ones that were signed",
    "annexes": "the attached files are not the ones that were signed",
}


def report_statement(st, annex_results, as_json):
    """Prints the statement result and returns the exit code. Partial stays partial:
    a verified statement with an annex that does not verify is not "verified"."""
    ok_annex = ("VERIFIED", "MULTI_VERIFIED")
    if as_json:
        out = {k: v for k, v in st.items() if k != "annexes"}
        out["annexes"] = [{"name": n, "kind": k, "bytes": len(b)} for n, k, b in st["annexes"]]
        out["annexResults"] = [{"name": n, **{k: v for k, v in r.items() if k in ("verdict", "reason", "proofCount", "verifiedCount")}}
                               for n, r in annex_results]
        print(json.dumps(out))
    else:
        print("A Validag Verification Statement.")
        if st["verdict"] != "VERIFIED":
            print(f"{st['verdict']}: {STATEMENT_EXPLAIN.get(st['reason'], st['reason'])}. Nothing else was checked.")
        else:
            inf = st["info"]
            print(f"VERIFIED: signed by {inf['issuer']}, statement {inf['statementId']} version {inf['version']}, issued {inf['issuedAt']}.")
            print(f"  Plot (from the signed record): {inf['plot']}")
            print(f"  Decision: {inf['decision']}" + (f" ({inf['note']})" if inf['note'] else ""))
            if inf["fieldCheck"]:
                for p in inf["fieldCheck"]["points"]:
                    said = "; ".join(f"{d['reportId']}: {d['status']}" + (f" ({d['reason']})" if d['reason'] else "") for d in p["declared"])
                    print(f"  {p['id']}: {p['verdict']} from {p['verifiedPhotos']} verified photo(s)" + (f"; the field report says {said}" if said else ""))
            print(f"  Timestamp: {st['timestamp']}")
            print("  This shows the statement is unchanged since Validag signed it. It does not show that the conclusion is right.")
            for name, kind, _ in st["annexes"]:
                r = dict(annex_results).get(name)
                print(f"  Annex {name} ({kind}): " + (r["verdict"] + (f" ({r['reason']})" if r.get("reason") else "") if r else "not checked (--statement-only)"))
    if st["verdict"] != "VERIFIED":
        return 1
    if any(r.get("verdict") not in ok_annex for _, r in annex_results) or len(annex_results) < len(st["annexes"]):
        return 1
    return 0


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",
    "report_part": "the report pages are not the ones the photographer signed (pages added, removed or changed)",
    "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")
    parser.add_argument("--keys", help="a Validag key list file to trust INSTEAD of the published one (test fixtures only)")
    parser.add_argument("--statement-only", action="store_true", help="a statement: check the statement, not its annexes")
    args = parser.parse_args()
    # A plot label may hold any script; a Windows console that cannot show a character
    # gets a replacement mark rather than a crash.
    try:
        sys.stdout.reconfigure(errors="replace")
    except AttributeError:
        pass

    statement = read_statement(args.pdf, args.keys)
    if statement and args.statement_only:
        sys.exit(report_statement(statement, [], args.json))

    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}).")

    if statement:
        annex_results = []
        if statement["verdict"] == "VERIFIED":
            for name, kind, data in statement["annexes"]:
                with tempfile.TemporaryDirectory() as tmp:
                    path = os.path.join(tmp, "annex.pdf")
                    with open(path, "wb") as fh:
                        fh.write(data)
                    try:
                        annex_results.append((name, verify(path, args.rpc, args.registry, args.poppler)))
                    except NotACertificate as e:
                        annex_results.append((name, {"verdict": "NOT_A_CERTIFICATE", "reason": str(e)}))
        sys.exit(report_statement(statement, annex_results, args.json))

    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.")
        rep_info = result.get("report")
        if rep_info:
            # Named as a Captilo report with its template, and what the check shows
            # (VERIFICATION_RULES.md 3.1, wording of 2026-10-29).
            template = REPORT_TEMPLATE_NAMES.get(rep_info.get("templateId", ""), "Report")
            if result["verdict"] in ("VERIFIED", "MULTI_VERIFIED"):
                print(f"Captilo report · {template}: pages 1-{rep_info['pages']} are unchanged since signing"
                      " and were signed by the same device as the photos.")
                print("  The details in the report were entered by its author. The check shows that they have"
                      " not changed since signing, not who wrote them or whether they are true.")
                if rep_info.get("sender"):
                    print("  From: " + "; ".join(rep_info["sender"]))
            else:
                print(f"Captilo report · {template}, pages 1-{rep_info['pages']}. Not every photo verified,"
                      " so the device that signed the report is not confirmed.")
    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()
