#!/usr/bin/env python3
"""UNPT CSI public verification kit (stdlib only, Python 3.10+).

Recomputes the published csi-v1.1.0 hashes from the public material under
``<base>/verify/`` and checks them against the served surfaces, without unpt_csi, a
database, or any third-party package.

    python verify_kit.py --base https://unpenned.org/data/csi/ [--period 2026] [--rpc <sepolia json-rpc url>]
    python verify_kit.py --base ./public/data/csi                 # a local checkout

Checks (each prints PASS/FAIL/WARN/SKIP; the exit code is non-zero on any FAIL and the table
is always printed; WARN marks a period whose anchored proof block certifies an earlier material
revision with the same value AND whose transitive anchor link fails, see ledgerMaterialDrift in
verify/index.json):

  files          every file listed in verify/index.json has the recorded sha256;
  material.chain snapshot.previousSnapshotHash of every period equals the recomputed snapshot
                 hash of the previous period; for each drift period (ledgerCertifiesMaterial
                 false) the first later block that certifies its material hashes
                 previous_snapshot_hash equal to the material snapshot hash of the period
                 before it, so that block transitively anchors the drift period (reported as
                 PASS 'transitively anchored via block #<seq>' on the period's proof-block row);
  material       for each selected period, the seven component hashes (methodology,
                 baselines, source registry, data bundle, metric values, contributions,
                 confidences) recompute from the material files with the canonicalisation
                 rule and equal the fields inside snapshot.json; the snapshot hash
                 recomputes and equals the published headline / proof-block snapshot hash;
  sub-indexes    100 + 100 * D * clip((effective - baseline) / baseline, lower, upper) at E8
                 equals every published sub-index valueE8 for the period;
  composition    sum(R_i / 101 * SubIndex_i) at E8 equals the published CSI E8 (abs diff <= 1
                 for the integer rounding order; the exact diff is reported);
  ledger         every proof block re-hashes over the hashed key set; previous_proof_hash
                 links from genesis to the head; anchored.json (when served) names the head;
  rpc            optional: eth_call CsiProofLedger.latestProofHash()/proofCount()/proofAt(n-1)
                 on Sepolia and compare with the served head.

Canonicalisation (unpt_csi.hashing.hash_json): sha256 over
``json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)`` encoded
UTF-8, prefixed ``sha256:``; Decimal values are plain decimal strings in the material.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import sys
import urllib.error
import urllib.request
from decimal import ROUND_HALF_UP, Decimal, localcontext
from pathlib import Path
from typing import Any

GENESIS_PREVIOUS_PROOF_HASH = "sha256:" + "0" * 64
# unpt_csi/proof_ledger/schemas.py HASHED_KEYS (also recorded in verify/index.json).
PROOF_BLOCK_HASHED_KEYS = (
    "schema",
    "ledger",
    "entry_type",
    "epoch",
    "period",
    "status",
    "data_state",
    "methodology_version",
    "csi",
    "delta_csi",
    "confidence",
    "methodology_hash",
    "config_hash",
    "baseline_hash",
    "source_registry_hash",
    "data_bundle_hash",
    "metric_values_hash",
    "contribution_hash",
    "confidence_hash",
    "snapshot_hash",
    "composition_hash",
    "member_hashes",
    "metric_root",
    "previous_proof_hash",
    "previous_snapshot_hash",
    "signer_quorum",
    "reference_value",
)
WEIGHT_DENOMINATOR = Decimal(101)
E8 = Decimal(10**8)
# CsiProofLedger.sol view selectors (keccak256 of the signature, first 4 bytes).
SELECTOR_LATEST_PROOF_HASH = "0x36ee19f5"  # latestProofHash()
SELECTOR_PROOF_COUNT = "0xaddecc06"  # proofCount()
SELECTOR_LATEST_EPOCH = "0x9cb118bf"  # latestEpoch()
SELECTOR_PROOF_AT = "0x26400694"  # proofAt(uint256) -> CsiProof (14 static words)


# ---------------------------------------------------------------------------
# canonicalisation + fetching
# ---------------------------------------------------------------------------
def canonical_json(value: Any) -> str:
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)


def hash_json(value: Any) -> str:
    return "sha256:" + hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def to_e8(value: Decimal) -> int:
    sign = -1 if value < 0 else 1
    return sign * int((abs(value) * E8).to_integral_value(rounding=ROUND_HALF_UP))


class Source:
    """Reads files under a base URL or a base directory."""

    def __init__(self, base: str) -> None:
        self.is_url = base.startswith("http://") or base.startswith("https://")
        self.base = base if self.is_url else str(Path(base).resolve())
        if self.is_url and not self.base.endswith("/"):
            self.base += "/"
        self._cache: dict[str, bytes] = {}

    def read(self, rel: str) -> bytes:
        if rel in self._cache:
            return self._cache[rel]
        if self.is_url:
            with urllib.request.urlopen(self.base + rel, timeout=60) as response:  # noqa: S310
                data = response.read()
        else:
            data = (Path(self.base) / rel).read_bytes()
        self._cache[rel] = data
        return data

    def exists(self, rel: str) -> bool:
        try:
            self.read(rel)
            return True
        except (OSError, urllib.error.URLError, urllib.error.HTTPError):
            return False

    def json(self, rel: str) -> Any:
        return json.loads(self.read(rel).decode("utf-8"))


class Report:
    def __init__(self) -> None:
        self.rows: list[tuple[str, str, str]] = []

    def add(self, status: str, check: str, detail: str = "") -> None:
        self.rows.append((status, check, detail))

    def ok(self, check: str, detail: str = "") -> None:
        self.add("PASS", check, detail)

    def fail(self, check: str, detail: str = "") -> None:
        self.add("FAIL", check, detail)

    def skip(self, check: str, detail: str = "") -> None:
        self.add("SKIP", check, detail)

    def warn(self, check: str, detail: str = "") -> None:
        self.add("WARN", check, detail)

    @property
    def failed(self) -> int:
        return sum(1 for status, _c, _d in self.rows if status == "FAIL")

    def print(self) -> None:
        width = max((len(check) for _s, check, _d in self.rows), default=10)
        for status, check, detail in self.rows:
            print(f"{status:<5} {check:<{width}}  {detail}")
        passed = sum(1 for s, _c, _d in self.rows if s == "PASS")
        skipped = sum(1 for s, _c, _d in self.rows if s == "SKIP")
        warned = sum(1 for s, _c, _d in self.rows if s == "WARN")
        print(f"\n{passed} passed, {self.failed} failed, {warned} warned, {skipped} skipped")


# ---------------------------------------------------------------------------
# checks
# ---------------------------------------------------------------------------
def check_files(src: Source, index: dict, report: Report) -> None:
    bad = []
    for entry in index.get("files", []):
        rel = entry["path"]
        try:
            digest = sha256_bytes(src.read(rel))
        except (OSError, urllib.error.URLError, urllib.error.HTTPError) as exc:
            bad.append(f"{rel}: unreadable ({exc})")
            continue
        if digest != entry["sha256"]:
            bad.append(f"{rel}: sha256 {digest} != {entry['sha256']}")
    if bad:
        report.fail("files.sha256", f"{len(bad)} mismatch(es): " + "; ".join(bad[:5]))
    else:
        report.ok("files.sha256", f"{len(index.get('files', []))} files match verify/index.json")


def load_material(src: Source, material_dir: str) -> dict[str, Any]:
    names = (
        "methodology",
        "baselines",
        "source-registry",
        "source-bundle",
        "metric-values",
        "contributions",
        "confidences",
        "snapshot",
    )
    return {name: src.json(f"{material_dir}/{name}.json") for name in names}


def latest_blocks_by_period(src: Source) -> dict[str, tuple[int, dict]]:
    index = src.json("ledger/index.json")
    entries = sorted(index["entries"], key=lambda e: int(e["append_sequence"]))
    out: dict[str, tuple[int, dict]] = {}
    for entry in entries:
        if entry.get("entry_type") != "CSI_SNAPSHOT_PUBLISHED":
            continue
        seq = int(entry["append_sequence"])
        out[str(entry["period"])] = (seq, src.json(f"ledger/entries/{seq:04d}/proof-block.json"))
    return out


def check_material_chain(
    src: Source, index: dict, blocks: dict[str, tuple[int, dict]], report: Report
) -> dict[str, int]:
    """Recompute the material snapshot chain over EVERY published period and derive, for
    each drift period (ledgerCertifiesMaterial false), the block that transitively anchors
    it: the first later period whose latest block certifies its material outright, provided
    that block's hashed previous_snapshot_hash equals the recomputed material snapshot hash
    of the period before it and every chain link in between holds. Returns
    {period: appendSequence} for the drift periods that are transitively anchored; the
    index's own perPeriod.transitivelyAnchoredVia claims are checked against the result."""
    per_period = index.get("perPeriod", {})
    ordered = sorted(per_period, key=lambda p: (int(per_period[p].get("epoch", 0)), p))
    recomputed: dict[str, dict] = {}
    for period in ordered:
        snapshot = src.json(f"{per_period[period]['materialDir']}/snapshot.json")
        recomputed[period] = {"hash": hash_json(snapshot), "previous": snapshot.get("previousSnapshotHash")}
    link_ok: dict[str, bool] = {}
    problems: list[str] = []
    for position in range(1, len(ordered)):
        current, previous = ordered[position], ordered[position - 1]
        ok = recomputed[current]["previous"] == recomputed[previous]["hash"]
        link_ok[current] = ok
        if not ok:
            problems.append(f"{current}.previousSnapshotHash != recomputed {previous} snapshot hash")
    anchored_via: dict[str, int] = {}
    claims: list[str] = []
    for position, period in enumerate(ordered):
        row = per_period[period]
        claim = row.get("transitivelyAnchoredVia")
        if row.get("ledgerCertifiesMaterial") is not False:
            if claim is not None:
                claims.append(f"{period}: index claims transitivelyAnchoredVia {claim} but the period is certified outright")
            continue
        found = None
        chain_holds = True
        for later in ordered[position + 1 :]:
            chain_holds = chain_holds and link_ok.get(later, False)
            later_row = per_period[later]
            if later_row.get("ledgerCertifiesMaterial") is True:
                block = blocks.get(later)
                before = ordered[ordered.index(later) - 1]
                if (
                    chain_holds
                    and block is not None
                    and block[1].get("previous_snapshot_hash") == recomputed[before]["hash"]
                    and block[1].get("snapshot_hash") == recomputed[later]["hash"]
                ):
                    found = block[0]
                break
        if found is not None:
            anchored_via[period] = found
        if claim != found:
            claims.append(f"{period}: index claims transitivelyAnchoredVia {claim}, recomputed {found}")
    if problems:
        report.fail("material.chain", "; ".join(problems[:5]))
    elif claims:
        report.fail("material.chain", "; ".join(claims[:5]))
    else:
        drift = [p for p in ordered if per_period[p].get("ledgerCertifiesMaterial") is False]
        anchors = sorted(set(anchored_via.values()))
        report.ok(
            "material.chain",
            f"{len(ordered)} periods link through previousSnapshotHash; {len(anchored_via)}/{len(drift)} drift "
            f"period(s) transitively anchored via block(s) {', '.join('#' + str(a) for a in anchors) or '-'}",
        )
    return anchored_via


def check_period_material(
    src: Source,
    index: dict,
    period: str,
    blocks: dict[str, tuple[int, dict]],
    report: Report,
    anchored_via: dict[str, int] | None = None,
) -> None:
    per_period = index["perPeriod"][period]
    material = load_material(src, per_period["materialDir"])
    snapshot = material["snapshot"]
    component_hashes = {
        "methodologyHash": hash_json(material["methodology"]),
        "baselineHash": hash_json(material["baselines"]),
        "sourceRegistryHash": hash_json(material["source-registry"]),
        "dataBundleHash": hash_json(material["source-bundle"]),
        "metricValuesHash": hash_json(material["metric-values"]),
        "contributionHash": hash_json(material["contributions"]),
        "confidenceHash": hash_json(material["confidences"]),
    }
    mismatched = [
        f"{key}: {value} != {snapshot.get(key)}"
        for key, value in component_hashes.items()
        if snapshot.get(key) != value
    ]
    if mismatched:
        report.fail(f"{period}.component-hashes", "; ".join(mismatched))
    else:
        report.ok(f"{period}.component-hashes", "7 component hashes recompute from the material files")
    if snapshot.get("configHash") != component_hashes["methodologyHash"]:
        report.fail(f"{period}.config-hash", "configHash != methodologyHash")

    snapshot_hash = hash_json(snapshot)
    published = per_period["snapshotHash"]
    if snapshot_hash != published:
        report.fail(f"{period}.snapshot-hash", f"recomputed {snapshot_hash} != index {published}")
    else:
        report.ok(f"{period}.snapshot-hash", snapshot_hash)
    if str(snapshot.get("csiE8")) != str(per_period["csiE8"]):
        report.fail(f"{period}.csiE8", f"material {snapshot.get('csiE8')} != index {per_period['csiE8']}")

    block = blocks.get(period)
    if block is None:
        report.fail(f"{period}.proof-block", "no CSI_SNAPSHOT_PUBLISHED block for the period")
    else:
        seq, body = block
        detail = []
        drift = per_period.get("ledgerCertifiesMaterial") is False
        if to_e8(Decimal(str(body.get("csi")))) != int(snapshot["csiE8"]):
            detail.append(f"block #{seq} csi {body.get('csi')} != material csiE8 {snapshot['csiE8']}")
        for key, field in (
            ("methodology_hash", "methodologyHash"),
            ("baseline_hash", "baselineHash"),
            ("source_registry_hash", "sourceRegistryHash"),
            ("metric_values_hash", "metricValuesHash"),
            ("contribution_hash", "contributionHash"),
            ("confidence_hash", "confidenceHash"),
        ):
            if body.get(key) != snapshot.get(field):
                detail.append(f"block #{seq} {key} != material {field}")
        if not drift:
            for key, field in (
                ("snapshot_hash", None),
                ("data_bundle_hash", "dataBundleHash"),
                ("previous_snapshot_hash", "previousSnapshotHash"),
            ):
                expected = snapshot_hash if field is None else snapshot.get(field)
                if body.get(key) != expected:
                    detail.append(f"block #{seq} {key} {body.get(key)} != recomputed {expected}")
        else:
            if body.get("snapshot_hash") != per_period.get("ledgerSnapshotHash") or body.get("data_bundle_hash") != per_period.get("ledgerDataBundleHash"):
                detail.append(f"block #{seq} snapshot/data-bundle hashes differ from the index's recorded ledger values")
        if body.get("proof_block_hash") != per_period["proofBlockHash"] or seq != int(per_period["appendSequence"]):
            detail.append(f"index perPeriod names block {per_period['appendSequence']} {per_period['proofBlockHash']}, ledger latest is #{seq}")
        if detail:
            report.fail(f"{period}.proof-block", "; ".join(detail))
        elif drift:
            via = (anchored_via or {}).get(period)
            if via is not None:
                report.ok(
                    f"{period}.proof-block",
                    f"#{seq} certifies an earlier material revision with the same CSI E8; the current material is "
                    f"transitively anchored via block #{via} (material chain + that block's previous_snapshot_hash "
                    "recomputed, see material.chain)",
                )
            else:
                report.warn(
                    f"{period}.proof-block",
                    f"#{seq} certifies an EARLIER material revision with the same CSI E8 (ledger dataBundleHash "
                    f"{body.get('data_bundle_hash')} vs material {snapshot.get('dataBundleHash')}); methodology, baseline, "
                    "registry, metric-values, contribution and confidence hashes agree; NOT transitively anchored "
                    "(see material.chain and index ledgerMaterialDrift)",
                )
        else:
            report.ok(f"{period}.proof-block", f"#{seq} {body['proof_block_hash']} carries the recomputed snapshot hash")

    if period == index.get("latestPeriod"):
        latest = src.json("latest.json")
        if latest.get("snapshotHash") != snapshot_hash or latest.get("sourceBundleHash") != component_hashes["dataBundleHash"]:
            report.fail(f"{period}.headline", "latest.json snapshotHash/sourceBundleHash != recomputed")
        else:
            report.ok(f"{period}.headline", f"latest.json value {latest.get('value')} carries the recomputed hashes")

    check_sub_indexes_and_composition(src, index, period, material, block, report)


def check_sub_indexes_and_composition(
    src: Source, index: dict, period: str, material: dict, block: tuple[int, dict] | None, report: Report
) -> None:
    methodology = material["methodology"]
    metrics = {m["id"]: m for m in methodology["metrics"]}
    baselines = material["baselines"]
    values = {row["metric_id"]: row for row in material["metric-values"]}
    sub_indexes = index["subIndexes"]
    problems: list[str] = []
    notes: list[str] = []
    recomputed_e8: dict[str, int] = {}
    with localcontext() as ctx:
        ctx.prec = 60
        for sub in sub_indexes:
            metric = metrics[sub["parentMetricId"]]
            row = values[sub["parentMetricId"]]
            base = Decimal(str(baselines[sub["parentMetricId"]]))
            eff = Decimal(str(row["effective_value"]))
            ybar = (eff - base) / base
            ybar = min(max(ybar, Decimal(str(metric["lower_relative_bound"]))), Decimal(str(metric["upper_relative_bound"])))
            if str(row["bounded_relative_deviation"]) != format(ybar, "f"):
                # informative only; the E8 sub-index comparison below is the check
                notes.append(f"{sub['subIndexId']}: material deviation string differs from the recomputed one")
            value = Decimal(100) + Decimal(100) * Decimal(int(metric["direction"])) * ybar
            recomputed_e8[sub["subIndexId"]] = to_e8(value)
    published_e8: dict[str, int] = {}
    for sub in sub_indexes:
        history = src.json(f"sub-indexes/{sub['slug']}/history.json")
        point = next((p for p in history["series"] if str(p["period"]) == period), None)
        if point is None:
            problems.append(f"{sub['subIndexId']}: no history point for {period}")
            continue
        published_e8[sub["subIndexId"]] = int(point["valueE8"])
        if published_e8[sub["subIndexId"]] != recomputed_e8[sub["subIndexId"]]:
            problems.append(f"{sub['subIndexId']}: published valueE8 {point['valueE8']} != recomputed {recomputed_e8[sub['subIndexId']]}")
    if problems:
        report.fail(f"{period}.sub-indexes", "; ".join(problems))
    else:
        report.ok(
            f"{period}.sub-indexes",
            "8 sub-index values recompute at E8 from effective values + baselines"
            + (f" (notes: {'; '.join(notes)})" if notes else ""),
        )

    # composition arithmetic: sum(R_i / 101 * SubIndex_i) at E8 vs the published CSI E8
    csi_e8 = int(material["snapshot"]["csiE8"])
    with localcontext() as ctx:
        ctx.prec = 60
        total = sum(
            (Decimal(sub["rawPriorityWeight"]) * Decimal(published_e8.get(sub["subIndexId"], 0)) for sub in sub_indexes),
            Decimal(0),
        ) / WEIGHT_DENOMINATOR
        composed_e8 = int(total.to_integral_value(rounding=ROUND_HALF_UP))
    weights = sum(int(sub["rawPriorityWeight"]) for sub in sub_indexes)
    diff = composed_e8 - csi_e8
    detail = f"composed {composed_e8} vs published {csi_e8} (diff {diff}; weights sum {weights})"
    if weights != 101 or len(sub_indexes) != 8 or abs(diff) > 1 or len(published_e8) != 8:
        report.fail(f"{period}.composition", detail)
    else:
        report.ok(f"{period}.composition", detail)
    if period == index.get("latestPeriod"):
        composition = src.json("composition/latest.json")
        members = {m["subIndexId"]: m for m in composition["members"]}
        bad = [
            f"{sid}: composition member {members[sid]['subIndexValueE8']} != history {e8}"
            for sid, e8 in published_e8.items()
            if sid not in members or int(members[sid]["subIndexValueE8"]) != e8
        ]
        with localcontext() as ctx:
            ctx.prec = 60
            comp_total = sum(
                (Decimal(m["rawPriorityWeight"]) * Decimal(m["subIndexValueE8"]) for m in composition["members"]),
                Decimal(0),
            ) / WEIGHT_DENOMINATOR
        comp_e8 = int(comp_total.to_integral_value(rounding=ROUND_HALF_UP))
        comp_diff = comp_e8 - int(composition["valueE8"])
        if bad or abs(comp_diff) > 1 or int(composition["valueE8"]) != csi_e8:
            report.fail(f"{period}.composition-latest", "; ".join(bad) + f" valueE8 {composition['valueE8']} composed {comp_e8} (diff {comp_diff})")
        else:
            report.ok(f"{period}.composition-latest", f"composition/latest.json valueE8 {composition['valueE8']} recomposes from its members (diff {comp_diff})")


def check_ledger(src: Source, index: dict, report: Report) -> dict | None:
    hashed_keys = set(index.get("proofBlockHashedKeys") or PROOF_BLOCK_HASHED_KEYS)
    if hashed_keys != set(PROOF_BLOCK_HASHED_KEYS):
        report.fail("ledger.hashed-keys", "verify/index.json proofBlockHashedKeys differ from the verifier's embedded set")
        return None
    ledger_index = src.json("ledger/index.json")
    entries = sorted(ledger_index["entries"], key=lambda e: int(e["append_sequence"]))
    if [int(e["append_sequence"]) for e in entries] != list(range(len(entries))):
        report.fail("ledger.chain", "append positions are not contiguous from 0")
        return None
    previous = GENESIS_PREVIOUS_PROOF_HASH
    problems = []
    head = None
    for entry in entries:
        seq = int(entry["append_sequence"])
        block = src.json(f"ledger/entries/{seq:04d}/proof-block.json")
        material = {k: v for k, v in block.items() if k in hashed_keys}
        if hash_json(material) != block.get("proof_block_hash"):
            problems.append(f"#{seq} does not re-hash to its proof_block_hash")
        if block.get("previous_proof_hash") != previous:
            problems.append(f"#{seq} previous_proof_hash breaks the chain")
        if entry.get("proof_block_hash") != block.get("proof_block_hash"):
            problems.append(f"#{seq} index row disagrees with the block")
        previous = block.get("proof_block_hash")
        head = (seq, block)
    if problems:
        report.fail("ledger.chain", "; ".join(problems[:5]))
    else:
        report.ok("ledger.chain", f"{len(entries)} blocks re-hash and link from genesis; head {previous}")
    headline = index.get("headline", {})
    if head is not None and headline.get("proofBlockHash") != head[1].get("proof_block_hash"):
        # the headline is the latest block for the latest period, which is normally the head
        latest_for_period = [e for e in entries if str(e["period"]) == str(headline.get("period")) and e.get("entry_type") == "CSI_SNAPSHOT_PUBLISHED"]
        if not latest_for_period or latest_for_period[-1]["proof_block_hash"] != headline.get("proofBlockHash"):
            report.fail("ledger.headline", "verify/index.json headline proofBlockHash is not the latest block for its period")
    if src.exists("ledger/anchored.json"):
        anchored = src.json("ledger/anchored.json")
        count = int(anchored.get("anchoredCount") or 0)
        wm_head = (anchored.get("head") or {}).get("proofBlockHash")
        if count > len(entries):
            report.fail("ledger.anchored", f"anchored.json names {count} blocks, chain has {len(entries)}")
        elif count == 0:
            report.skip("ledger.anchored", "anchoredCount is 0")
        else:
            block = src.json(f"ledger/entries/{count - 1:04d}/proof-block.json")
            if block.get("proof_block_hash") != wm_head:
                report.fail("ledger.anchored", f"anchored head {wm_head} != block #{count - 1}")
            else:
                tail = len(entries) - count
                report.ok("ledger.anchored", f"anchored.json head = block #{count - 1} {wm_head}; {tail} unanchored tail block(s)")
        return {"anchored": anchored, "entries": len(entries), "head": head}
    report.skip("ledger.anchored", "ledger/anchored.json not served (no on-chain watermark)")
    return {"anchored": None, "entries": len(entries), "head": head}


def eth_call(rpc: str, to: str, data: str) -> str:
    payload = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "eth_call", "params": [{"to": to, "data": data}, "latest"]}).encode("utf-8")
    request = urllib.request.Request(rpc, data=payload, headers={"content-type": "application/json"})
    with urllib.request.urlopen(request, timeout=60) as response:  # noqa: S310
        body = json.loads(response.read().decode("utf-8"))
    if "error" in body:
        raise RuntimeError(str(body["error"]))
    return str(body["result"])


def check_rpc(rpc: str, ledger_state: dict | None, report: Report) -> None:
    if not ledger_state or not ledger_state.get("anchored"):
        report.skip("rpc.anchored", "no anchored.json watermark to compare against")
        return
    anchored = ledger_state["anchored"]
    address = anchored.get("address")
    if not address:
        report.skip("rpc.anchored", "anchored.json carries no contract address")
        return
    try:
        latest_hash = eth_call(rpc, address, SELECTOR_LATEST_PROOF_HASH)
        count_hex = eth_call(rpc, address, SELECTOR_PROOF_COUNT)
        count = int(count_hex, 16)
        latest_epoch = int(eth_call(rpc, address, SELECTOR_LATEST_EPOCH), 16)
        proof_at = eth_call(rpc, address, SELECTOR_PROOF_AT + f"{count - 1:064x}") if count else ""
    except (OSError, RuntimeError, ValueError, urllib.error.URLError) as exc:
        report.fail("rpc.anchored", f"eth_call failed: {exc}")
        return
    wm_head = str((anchored.get("head") or {}).get("proofBlockHash") or "")
    expected = wm_head.replace("sha256:", "")
    on_chain = latest_hash[2:].rjust(64, "0")[-64:]
    detail = f"latestProofHash {on_chain[:16]}... proofCount {count} latestEpoch {latest_epoch}"
    problems = []
    if on_chain != expected:
        problems.append(f"latestProofHash != anchored head {expected[:16]}...")
    if count != int(anchored.get("anchoredCount") or 0):
        problems.append(f"proofCount {count} != anchoredCount {anchored.get('anchoredCount')}")
    if proof_at:
        words = proof_at[2:]
        if len(words) >= 14 * 64:
            proof_hash_word = words[10 * 64 : 11 * 64]
            epoch_word = int(words[0:64], 16)
            if proof_hash_word != expected:
                problems.append("proofAt(count-1).proofHash != anchored head")
            if epoch_word != count:
                problems.append(f"proofAt(count-1).epoch {epoch_word} != append position + 1 ({count})")
        else:
            problems.append("proofAt returned an unexpected word count (skipped: needs abi)")
    if problems:
        report.fail("rpc.anchored", detail + "; " + "; ".join(problems))
    else:
        report.ok("rpc.anchored", detail + f" == anchored head at {address}")


# ---------------------------------------------------------------------------
# entry point
# ---------------------------------------------------------------------------
def run(base: str, periods: list[str] | None = None, rpc: str | None = None) -> Report:
    report = Report()
    src = Source(base)
    try:
        index = src.json("verify/index.json")
    except (OSError, urllib.error.URLError, urllib.error.HTTPError, ValueError) as exc:
        report.fail("index", f"verify/index.json unreadable: {exc}")
        return report
    if index.get("schema") != "unpt.csi.verify.v1":
        report.fail("index", f"unexpected schema {index.get('schema')!r}")
        return report
    report.ok("index", f"{index.get('methodologyVersion')} {index.get('certificationLabel')} latest {index.get('latestPeriod')}")
    guarded = (OSError, ValueError, KeyError, TypeError, AttributeError, IndexError, urllib.error.URLError)
    try:
        check_files(src, index, report)
    except guarded as exc:
        report.fail("files.sha256", f"{type(exc).__name__}: {exc}")
    ledger_state = None
    try:
        ledger_state = check_ledger(src, index, report)
    except guarded as exc:
        report.fail("ledger.chain", f"{type(exc).__name__}: {exc}")
    try:
        blocks = latest_blocks_by_period(src)
    except guarded as exc:
        report.fail("ledger.blocks", f"{type(exc).__name__}: {exc}")
        blocks = {}
    anchored_via: dict[str, int] = {}
    try:
        anchored_via = check_material_chain(src, index, blocks, report)
    except guarded as exc:
        report.fail("material.chain", f"{type(exc).__name__}: {exc}")
    selected = periods or list(index.get("periods", []))
    for period in selected:
        if period not in index.get("perPeriod", {}):
            report.fail(f"{period}.material", "period not published in verify/index.json")
            continue
        try:
            check_period_material(src, index, period, blocks, report, anchored_via)
        except guarded as exc:
            report.fail(f"{period}.material", f"{type(exc).__name__}: {exc}")
    if rpc:
        try:
            check_rpc(rpc, ledger_state, report)
        except guarded as exc:
            report.fail("rpc.anchored", f"{type(exc).__name__}: {exc}")
    return report


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="UNPT CSI public verification kit")
    parser.add_argument("--base", required=True, help="URL or local directory of the public data/csi tree")
    parser.add_argument("--period", action="append", help="period(s) to verify (default: every published period)")
    parser.add_argument("--rpc", help="optional Sepolia JSON-RPC URL for the on-chain anchor comparison")
    args = parser.parse_args(argv)
    report = run(args.base, args.period, args.rpc)
    report.print()
    return 1 if report.failed else 0


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