#!/usr/bin/env python3
"""
verify.py — independent chain-of-custody verifier for Fato evidence.

Standalone CLI that checks whether a recording file + its server-issued
manifest are self-consistent.  No DB access and no Fato infrastructure — a
court-appointed expert should be able to run this against just the artifacts
found inside an evidence package.

USAGE

  Step 1 — extract the evidence package using the video SHA-256 as the
  password (any AES-zip tool: 7-Zip, Keka, WinRAR):

      7z x fato-evidencia-<id>.zip -p<video-sha256>

  The package contains the video, ``manifest.json`` (the signed
  chain-of-custody manifest), the ``<id>.events.jsonl`` log, and the report PDF.

  Step 2 — verify the extracted artifacts.  ``--server-pubkey`` carries Fato's
  genuine ed25519 public key, obtained OUT OF BAND (never from the bundle); it
  is required unless a key is pinned in ``FATO_SERVER_PUBKEYS`` below:

      python verify.py --manifest manifest.json \
                       --file <id>.webm \
                       --events <id>.events.jsonl \
                       --server-pubkey <chave-publica-fato>

  Or pull the manifest straight from a running API with a bearer token:

      python verify.py --url http://localhost:8000 --token <JWT> \
                       --session-id <uuid> --file <id>.webm \
                       --server-pubkey <chave-publica-fato>

EXIT CODES
  0  PASS — every link was checked and every check passed
  1  FAIL — at least one check failed
  2  usage / io error
  3  INCOMPLETE — nothing failed, but at least one link could not be checked

  0 and 3 are deliberately distinct. An INCOMPLETE result used to exit 0 and
  emit "pass": true in --json, so a script or a CI gate could not tell a
  complete verification from a partial one.

WHAT IT CHECKS

  1. Server signature over the canonical manifest verifies with the trusted
     Fato ed25519 public key (--server-pubkey / FATO_SERVER_PUBKEYS), never
     with the key the bundle advertises about itself.
  1b. The sealed manifest's format version is one this copy understands. A
     bundle from a NEWER format is reported INCOMPLETE, never FAIL and never
     PASS: nothing is wrong with it, but an older verifier cannot claim to
     have checked links it does not know about. A manifest with no version
     key predates the field and is verified exactly as before.
  2. SHA-256 of the recording file matches `manifest.file_sha256`.
  3. The metadata signature verifies against the client public key in the
     signed manifest, over the canonical JSON of the signed metadata frame.
     That frame is not part of today's bundle, so unless it is supplied with
     --client-metadata this link is reported UNVERIFIED — never as a pass.
     (Even when it verifies it confirms the signer held the private key; it
     does NOT prove who that person is — identity linkage is separate.)
  4. The page-authenticity events-log SHA-256 matches the value sealed in
     the signed manifest.
  5. The terminal chunk-chain hash the CLIENT computed while recording equals
     the one the SERVER computed while receiving. A mismatch is the primary
     indicator of upload-side tampering. Note this compares two attested
     values from the signed manifest — the chain cannot be recomputed from the
     recording, because chunk boundaries are not recorded anywhere.
     A recording sealed without a client hash (an inactivity-swept session)
     reports UNVERIFIED, never FAIL.
  6. The server did not seal the recording with tamper_detected=true.

A link this tool could not check is reported as UNVERIFIED and the banner
reads INCOMPLETE: it is neither a pass nor a failure, and an incomplete chain
is never presented as a complete PASS.

Both the canonical manifest JSON and the signature are stored verbatim
server-side so this tool does NOT need to know our canonicalization
rules — it verifies the bytes it was given.
"""
from __future__ import annotations

import argparse
import base64
import hashlib
import json
import sys
from pathlib import Path
from typing import Optional

# Distinct from 0 (PASS) so a caller can tell a complete verification from a
# partial one; see EXIT CODES above.
EXIT_INCOMPLETE = 3

# The newest sealed-manifest format this verifier understands. Mirrors
# signing.MANIFEST_VERSION on the server; the two move together.
#
# A bundle claiming a HIGHER version is not a failure — nothing about it is
# wrong, this copy is simply too old to say it checked everything. That is
# what INCOMPLETE exists for, and getting it wrong in the other direction is
# the dangerous one: a verifier that returns PASS on a format it does not
# understand tells a court every link was checked when some were not read at
# all.
KNOWN_MANIFEST_VERSION = 2

# A manifest with no `manifest_version` key predates the field. The absence IS
# the version — it cannot be added to those bundles without breaking the
# signatures that make them evidence — so it is named rather than defaulted
# silently.
MANIFEST_VERSION_UNVERSIONED = 1


def _b64url_decode(s: str) -> bytes:
    """Decode base64url, tolerating surrounding whitespace.

    Matches api/signing.py, which strips. Without this a --server-pubkey
    copy-pasted with a trailing newline raises binascii.Error, the trust-anchor
    loop swallows it, and the tool tells an expert who DID supply the key that
    no trust anchor was provided.
    """
    s = (s or "").strip()
    pad = "=" * (-len(s) % 4)
    return base64.urlsafe_b64decode(s + pad)


def _canonical_json(obj) -> str:
    """Byte-stable JSON.  MUST match ``api/signing.py:canonical_json`` — sorted
    keys, compact separators, non-ASCII kept verbatim — because that is the
    exact byte string the client signed."""
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)


def _sha256_file(path: Path, chunk: int = 1 << 20) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        while True:
            buf = f.read(chunk)
            if not buf:
                break
            h.update(buf)
    return h.hexdigest()


# -- Trust anchor -----------------------------------------------------------
# The verifier MUST NOT trust the public key carried inside the bundle it is
# verifying — that key is attacker-controlled and self-referential.  Pin the
# genuine Fato production ed25519 public key(s) here (obtained out of band: a
# signed PDF, a .well-known URL over TLS, or a court filing) and/or supply one
# at runtime with --server-pubkey.  A bundle whose server_public_key does not
# match a pinned/supplied anchor is REJECTED, regardless of whether its self
# signature is internally consistent.
#
# Populate with the raw (base64/base64url) ed25519 public key(s) Fato publishes.
FATO_SERVER_PUBKEYS: list[str] = []


def _load_ed25519_pub(pub_b64: str):
    from cryptography.hazmat.primitives.asymmetric import ed25519
    return ed25519.Ed25519PublicKey.from_public_bytes(_b64url_decode(pub_b64))


def _pubkey_raw(pub_b64: str) -> bytes:
    """Decode a base64/base64url ed25519 public key to its 32 raw bytes so two
    encodings of the same key compare equal."""
    return _b64url_decode(pub_b64)


def _pubkey_fingerprint(pub_b64: str) -> str:
    try:
        return hashlib.sha256(_pubkey_raw(pub_b64)).hexdigest()
    except Exception:
        return "<undecodable>"


def _check_client_metadata_signature(manifest: dict, supplied) -> dict:
    """Verify the client's ed25519 signature over the metadata frame.

    Signing contract (``api/signing.py:verify_client_signature``): the signature
    covers the canonical JSON of the metadata frame with its own ``signature``
    field removed.

    The frame itself is NOT stored verbatim server-side today, so it usually
    isn't in the bundle.  When it is absent this link is reported UNVERIFIED —
    the mere presence of a pubkey and a signature string proves nothing (an
    attacker writes both), and presenting it as a tick would misrepresent an
    unchecked link as a verified one.
    """
    pub_b64 = manifest.get("client_pubkey")
    sig_b64 = manifest.get("metadata_signature")
    fp = _pubkey_fingerprint(pub_b64) if pub_b64 else None
    if not pub_b64 or not sig_b64:
        return {
            "pass": False,
            "error": (
                "manifest carries no client_pubkey / metadata_signature — the "
                "recording is not bound to any client key"
            ),
        }
    payload = supplied if supplied is not None else manifest.get("signed_metadata")
    if not isinstance(payload, dict):
        return {
            "pass": False,
            "unverified": True,
            "note": (
                "UNVERIFIED — the signed metadata frame is not part of this "
                "bundle, so the client signature cannot be checked offline. "
                "Supply the frame with --client-metadata <file> to verify it. "
                "A client_pubkey and a metadata_signature being present proves "
                "nothing on its own."
            ),
            "client_pubkey_fingerprint": fp,
        }
    try:
        unsigned = {k: v for k, v in payload.items() if k != "signature"}
        pub = _load_ed25519_pub(pub_b64)
        pub.verify(
            _b64url_decode(sig_b64), _canonical_json(unsigned).encode("utf-8")
        )
        return {
            "pass": True,
            "client_pubkey_fingerprint": fp,
            # Without this the terminal prints a bare tick and the caveat lives
            # only in the module docstring, which the reader of a report has
            # not got in front of them.
            "note": "proves possession of the client private key, NOT the "
                    "identity of the person holding it",
        }
    except Exception as exc:
        return {
            "pass": False,
            "error": repr(exc),
            "client_pubkey_fingerprint": fp,
        }


def _check_chunk_chain(manifest: dict) -> dict:
    """Compare the server's terminal chain hash against the client's.

    This is the system's primary indicator of upload-side tampering. The server
    recomputes H(n) = SHA-256(H(n-1) || chunk) as bytes arrive (api/main.py) and
    the client sends the terminal value it computed independently. A mismatch
    means the bytes the server stored are not the bytes the client recorded.

    LIMIT, stated plainly because omitting it would repeat the very mistake this
    tool exists to avoid: this compares two ATTESTED values carried in the signed
    manifest. It does NOT recompute the chain from the recording — chunk
    boundaries are recorded nowhere, so they cannot be reconstructed offline.
    Check 1 is what makes these two values trustworthy; without it they are
    just numbers in a file.
    """
    server_h = manifest.get("terminal_chunk_hash_server")
    client_h = manifest.get("terminal_chunk_hash_client")
    sealed_verdict = manifest.get("chunk_chain_verified")

    if "terminal_chunk_hash_server" not in manifest:
        # A recording sealed before this field existed. The manifest is
        # authentic (check 1 says so) and simply predates the mechanism, so
        # FAILING it would tell an expert that valid archived evidence is bad.
        # The link genuinely cannot be checked for this vintage: UNVERIFIED.
        return {
            "unverified": True,
            "note": (
                "UNVERIFIED — this manifest predates the chunk-chain fields, so "
                "the upload-side integrity comparison does not apply to it"
            ),
        }

    if not server_h:
        # Present but empty/null is different: the seal claims the field and
        # then does not supply it, which is malformed, not merely old.
        return {
            "pass": False,
            "error": (
                "manifest declares terminal_chunk_hash_server but its value is "
                "empty — the seal is malformed"
            ),
        }

    if client_h is None:
        # Legitimate and common: a session swept by the inactivity reaper is
        # sealed without the client's stop frame. Not a failure — but not a pass
        # either, because the strongest tamper signal never ran.
        return {
            "unverified": True,
            "note": (
                "UNVERIFIED — the client never sent its terminal chunk hash "
                f"(finalized_by={manifest.get('finalized_by') or 'unknown'}), so "
                "the upload-side integrity comparison could not run"
            ),
            "server": server_h,
        }

    if client_h != server_h:
        return {
            "pass": False,
            "error": (
                "TERMINAL CHUNK HASH MISMATCH — the chain the client computed "
                "while recording does not match the chain the server computed "
                "while receiving. The stored bytes are not the bytes the client "
                "recorded."
            ),
            "server": server_h,
            "client": client_h,
        }

    if sealed_verdict is False:
        return {
            "pass": False,
            "error": (
                "the two terminal hashes match, but the manifest seals "
                "chunk_chain_verified=false — the seal contradicts itself and "
                "cannot be relied on either way"
            ),
            "server": server_h,
            "client": client_h,
        }

    if sealed_verdict is not True:
        # Present-but-null, or any non-boolean. The hashes agreeing is our own
        # comparison; the server's recorded verdict is a separate attestation,
        # and a missing one must not be inferred from it. Anything other than
        # an explicit true leaves that attestation absent.
        return {
            "unverified": True,
            "note": (
                "UNVERIFIED — the two terminal hashes match, but the manifest "
                f"carries no boolean chunk_chain_verified (got {sealed_verdict!r}), "
                "so the server's own verdict on the chain is unrecorded"
            ),
            "server": server_h,
            "client": client_h,
        }

    return {"pass": True, "terminal_hash": server_h}


def _check_tamper_flag(manifest: dict) -> dict:
    """Surface the tamper verdict the server sealed into the signed manifest.

    A recording the system itself flagged as tampered must never print PASS.
    The wording is deliberately narrow: this reports what the capture
    environment OBSERVED, which is not the same claim as the bytes having been
    altered — that is what check 5 covers.
    """
    if "tamper_detected" not in manifest:
        # Absence of a claim is not a negative claim. Reading a missing field
        # as "no tampering" is the same fail-open shape this check exists to
        # remove.
        return {
            "unverified": True,
            "note": (
                "UNVERIFIED — this manifest carries no tamper_detected field, "
                "so the server's tamper verdict is unknown for this recording"
            ),
        }

    flag = manifest.get("tamper_detected")

    if not isinstance(flag, bool):
        # Present but null, or any non-boolean. Treating that as False is the
        # same fail-open shape as treating it as absent: an unrecorded verdict
        # read as a negative one.
        return {
            "unverified": True,
            "note": (
                "UNVERIFIED — tamper_detected is present but not a boolean "
                f"(got {flag!r}), so the server's tamper verdict is unrecorded"
            ),
        }

    if flag:
        return {
            "pass": False,
            "error": (
                "the server sealed this recording with tamper_detected=true. "
                "This records what the capture environment observed during the "
                "session (see tamper_events in the bundle); it is a separate "
                "claim from byte-level alteration, which check 5 covers."
            ),
        }
    return {"pass": True}


def _check_manifest_version(manifest: dict) -> dict:
    """Which format era sealed this bundle, and whether this copy knows it.

    Three answers, and the third is the reason the check exists:

      * key absent — sealed before versioning. Verified exactly as before,
        because nothing this tool checks changed between era 1 and era 2.
      * a version this copy knows — verified normally.
      * a HIGHER version — reported UNVERIFIED, which makes the run
        INCOMPLETE rather than PASS. Nothing is wrong with the bundle; this
        copy of the verifier is older than the format and cannot honestly say
        it checked every link. A court-appointed expert needs to be told to
        fetch a current verifier, not told the evidence failed.

    A non-integer version is a FAILURE, not an unknown. The field is inside
    the signature, so anything but a whole number means the manifest is
    malformed in a way the server would never have produced.
    """
    raw = manifest.get("manifest_version", MANIFEST_VERSION_UNVERSIONED)

    # `bool` first: it subclasses int, so `true` would read as version 1.
    if isinstance(raw, bool) or not isinstance(raw, int):
        return {
            "pass": False,
            "manifest_version": raw,
            "error": (
                "manifest_version is not a whole number — the signed manifest "
                "is malformed"
            ),
        }

    if raw > KNOWN_MANIFEST_VERSION:
        return {
            "unverified": True,
            "manifest_version": raw,
            "known": KNOWN_MANIFEST_VERSION,
            # `note`, which is what every other unverified check in this file
            # uses and what the terminal renderer prints. Spelled `reason`
            # first, and the message — the entire point of the check — reached
            # nobody who did not pass --json.
            "note": (
                f"UNVERIFIED — this bundle was sealed in format v{raw}; this "
                f"verifier understands up to v{KNOWN_MANIFEST_VERSION}. "
                "Nothing is wrong with the evidence: download a current "
                "verify.py before reporting on it."
            ),
        }

    if raw < MANIFEST_VERSION_UNVERSIONED:
        return {
            "pass": False,
            "manifest_version": raw,
            "error": "manifest_version is below the first valid version",
        }

    return {
        "pass": True,
        "manifest_version": raw,
        "unversioned": raw == MANIFEST_VERSION_UNVERSIONED,
    }


def _verify(result: dict) -> bool:
    """Mutates `result['checks']` with pass/fail details.  Returns overall bool.

    A check marked ``unverified`` is neither a pass nor a failure: it is a link
    this tool could not check with the artifacts it was given.  Such links are
    excluded from the verdict and reported separately so an incomplete chain is
    never presented as a complete PASS.
    """
    pubkey_b64 = result["_input"]["server_public_key"]
    manifest_json = result["_input"]["manifest"]
    sig_b64 = result["_input"]["signature"]
    recording_path = Path(result["_input"]["file"])

    checks = result["checks"] = {}

    # 1. Server signature over manifest bytes.
    #
    # The verification key is taken from a TRUST ANCHOR (pinned constant or
    # --server-pubkey), never blindly from the bundle.  We still record the
    # fingerprint of the key the bundle advertised so the expert can compare it
    # manually, but a bundle whose advertised key is not the anchored Fato key
    # is an explicit FAILURE — a self-consistent signature under an unknown key
    # proves only that the bundle's author signed their own bytes.
    trusted = result["_input"].get("_trusted_server_pubkeys") or []
    fp = _pubkey_fingerprint(pubkey_b64)
    try:
        bundle_raw = _pubkey_raw(pubkey_b64)
    except Exception as exc:
        bundle_raw = None
        checks["server_signature"] = {
            "pass": False,
            "error": f"unable to decode server_public_key: {exc!r}",
            "key_fingerprint": fp,
        }
    if bundle_raw is not None:
        trusted_raw = []
        for t in trusted:
            try:
                trusted_raw.append(_pubkey_raw(t))
            except Exception:
                continue
        if not trusted_raw:
            checks["server_signature"] = {
                "pass": False,
                "error": (
                    "no trust anchor: the bundle's server_public_key cannot be "
                    "trusted on its own. Supply Fato's published key with "
                    "--server-pubkey <key> (or pin it in FATO_SERVER_PUBKEYS)."
                ),
                "key_fingerprint": fp,
            }
        elif bundle_raw not in trusted_raw:
            checks["server_signature"] = {
                "pass": False,
                "error": (
                    "server_public_key does not match the trusted Fato key — "
                    "the bundle is signed by an unrecognised key."
                ),
                "key_fingerprint": fp,
            }
        else:
            try:
                pub = _load_ed25519_pub(pubkey_b64)
                pub.verify(_b64url_decode(sig_b64), manifest_json.encode("utf-8"))
                checks["server_signature"] = {"pass": True, "key_fingerprint": fp}
            except Exception as exc:
                checks["server_signature"] = {
                    "pass": False,
                    "error": repr(exc),
                    "key_fingerprint": fp,
                }

    try:
        manifest = json.loads(manifest_json)
    except Exception as exc:
        checks["manifest_parse"] = {"pass": False, "error": repr(exc)}
        return False

    # 1b. Which era's rules apply.
    #
    # AFTER the signature and not before it, deliberately: the version decides
    # what every check below means, so it has to be one the server sealed
    # rather than one whoever handed over the bundle typed. Check 1 is what
    # makes reading it off this dict worth anything.
    checks["manifest_version"] = _check_manifest_version(manifest)
    if checks["manifest_version"].get("unverified"):
        # STOP HERE. Every check below reads fields out of this manifest and
        # compares them under THIS era's rules. Running them against a format
        # this copy does not know produces confident answers about claims that
        # may no longer mean what is assumed — and a mismatch is reported as
        # FAIL, which tells a court the evidence is bad when the only thing
        # out of date is the tool. Measured, not feared: a v99 bundle whose
        # terminal chunk hash meant something else came back FAIL, exit 1.
        #
        # The signature above is the exception and is deliberately left
        # standing: it is computed over BYTES, so it holds whatever the fields
        # inside them turn out to mean, and "this bundle is authentically
        # Fato's, but sealed after my time" is exactly the useful answer.
        unknown = checks["manifest_version"]["manifest_version"]
        for name in (
            "file_sha256",
            "client_metadata_signature",
            "events_log_sha256",
            "chunk_chain",
            "tamper_detected",
        ):
            checks[name] = {
                "unverified": True,
                "note": (
                    f"UNVERIFIED — not checked, because this verifier does not "
                    f"know format v{unknown}"
                ),
            }
        return all(
            c.get("pass") for c in checks.values()
            if not c.get("unverified") and not c.get("not_applicable")
        )

    # 2. File SHA-256 matches manifest claim
    expected = manifest.get("file_sha256")
    if not recording_path.exists():
        checks["file_sha256"] = {"pass": False, "error": f"file not found: {recording_path}"}
    elif not expected:
        checks["file_sha256"] = {"pass": False, "error": "manifest has no file_sha256"}
    else:
        actual = _sha256_file(recording_path)
        checks["file_sha256"] = {
            "pass": actual == expected,
            "manifest": expected,
            "computed": actual,
        }

    # 3. Client metadata signature — actually verified, never merely observed.
    checks["client_metadata_signature"] = _check_client_metadata_signature(
        manifest, result["_input"].get("client_metadata")
    )

    # 4. Page-authenticity events log hash (only if the manifest sealed one).
    # Soft-pass when the log isn't supplied — the sealed hash is still proven
    # to be part of the signed manifest by check 1.
    expected_events = manifest.get("events_log_sha256")
    events_path = result["_input"].get("events")
    if expected_events:
        if events_path and Path(events_path).exists():
            actual_events = _sha256_file(Path(events_path))
            checks["events_log_sha256"] = {
                "pass": actual_events == expected_events,
                "manifest": expected_events,
                "computed": actual_events,
            }
        else:
            # The manifest sealed an events-log hash but the log was not
            # supplied (or is missing on disk).  A check that never ran must
            # NEVER count as a pass — that silently drops one link of the chain
            # of custody.  Fail explicitly so the overall verdict is not PASS.
            checks["events_log_sha256"] = {
                "pass": False,
                "error": (
                    "manifest sealed an events log but none was supplied; pass "
                    "--events <file> (expected <recording_id>.events.jsonl from "
                    "the bundle) to verify it"
                ),
                "manifest": expected_events,
            }
    else:
        # A null events_log_sha256 is legitimate (no events log existed), but
        # the row must still appear. Silently omitting it left the reader
        # counting four ticks with no way to tell a link was absent.
        checks["events_log_sha256"] = {
            "not_applicable": True,
            "note": (
                "NOT APPLICABLE — this recording sealed no events-log hash, so "
                "there is no page-authenticity log to check"
            ),
        }

    # 5. Upload-side integrity: the client's chain vs the server's.
    checks["chunk_chain"] = _check_chunk_chain(manifest)

    # 6. The server's own sealed tamper verdict.
    checks["tamper_detected"] = _check_tamper_flag(manifest)

    # Three states are excluded from the verdict for two different reasons:
    #   unverified     — a link exists but could not be checked here. Blocks a
    #                    complete PASS (the caller reports INCOMPLETE).
    #   not_applicable — no such link exists in this recording, and check 1
    #                    proves that statement is authentic. Nothing is missing,
    #                    so it must NOT block PASS, or a recording with no
    #                    events log could never verify completely.
    return all(
        c.get("pass") for c in checks.values()
        if not c.get("unverified") and not c.get("not_applicable")
    )


def _load_manifest_from_file(path: Path) -> dict:
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def _load_manifest_from_api(base_url: str, token: str, session_id: str) -> dict:
    import urllib.request
    req = urllib.request.Request(
        f"{base_url.rstrip('/')}/recordings/{session_id}/manifest",
        headers={"Authorization": f"Bearer {token}"},
    )
    with urllib.request.urlopen(req, timeout=10) as r:
        return json.loads(r.read())


def _make_output_encodable() -> None:
    """Keep the report printable when stdout cannot encode the marks.

    On Windows the console is UTF-8 but a REDIRECTED stdout uses the locale
    encoding (typically cp1252), which cannot represent the tick, cross or
    em dash. An expert running `fato-verify ... > laudo.txt` would otherwise
    get a UnicodeEncodeError and a truncated file instead of a verdict.
    """
    try:
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[attr-defined]
    except Exception:
        pass


# Options whose value is base64url and can therefore legitimately begin with
# "-", which argparse would otherwise read as the start of another option.
_B64_VALUE_OPTIONS = ("--server-pubkey",)


def _rescue_dash_values(argv: list[str]) -> list[str]:
    """Rewrite `--server-pubkey <-value>` into `--server-pubkey=<-value>`.

    base64url uses "-" and "_" as its last two characters, so roughly 1 in 64
    ed25519 public keys encodes to a string starting with "-". argparse reads
    that as another option and fails with "expected one argument" — exit 2,
    which this tool documents as a usage error. The expert is told they typed
    the command wrong when they typed it exactly as published, and the only
    way out is a "=" form nobody thought to document.

    Rewriting here keeps the published command working for every key.
    """
    out: list[str] = []
    i = 0
    while i < len(argv):
        arg = argv[i]
        if arg in _B64_VALUE_OPTIONS and i + 1 < len(argv):
            value = argv[i + 1]
            # Only rescue a value that is not itself one of our options.
            if value.startswith("-") and value not in _B64_VALUE_OPTIONS:
                out.append(f"{arg}={value}")
                i += 2
                continue
        out.append(arg)
        i += 1
    return out


def main(argv: Optional[list[str]] = None) -> int:
    p = argparse.ArgumentParser(description=__doc__.split("USAGE")[0].strip())
    src = p.add_mutually_exclusive_group(required=True)
    src.add_argument("--manifest", help="Path to manifest JSON file (offline mode).")
    src.add_argument("--url", help="API base URL; requires --token + --session-id.")
    p.add_argument("--token", help="Bearer JWT (used with --url).")
    p.add_argument("--session-id", help="Recording UUID (used with --url).")
    p.add_argument("--file", required=True, help="Path to the .webm/.mp4 recording file.")
    p.add_argument(
        "--events",
        help="Path to the {session}.events.jsonl log (optional; verifies the "
        "sealed events-log hash).",
    )
    p.add_argument(
        "--server-pubkey",
        action="append",
        default=None,
        help="Fato's genuine server ed25519 public key (base64), obtained out "
        "of band. REQUIRED unless a key is pinned in FATO_SERVER_PUBKEYS. May "
        "be given more than once for key rotation. A bundle whose embedded "
        "server_public_key does not match is rejected.",
    )
    p.add_argument(
        "--client-metadata",
        help="Path to the JSON metadata frame the client signed (the frame sent "
        "on the WebSocket, including its 'signature' field). Without it the "
        "client-signature link is reported UNVERIFIED instead of passing.",
    )
    p.add_argument("--json", action="store_true", help="Print result as JSON.")
    args = p.parse_args(_rescue_dash_values(list(argv if argv is not None else sys.argv[1:])))
    _make_output_encodable()

    if args.manifest:
        try:
            data = _load_manifest_from_file(Path(args.manifest))
        except OSError as exc:
            print(f"error: {exc}", file=sys.stderr)
            return 2
    else:
        if not args.token or not args.session_id:
            print("error: --url requires --token and --session-id", file=sys.stderr)
            return 2
        try:
            data = _load_manifest_from_api(args.url, args.token, args.session_id)
        except Exception as exc:
            print(f"error fetching manifest: {exc}", file=sys.stderr)
            return 2

    # Type-check as well as presence: these three go straight into signature
    # verification and json.loads, so a bundle that ships `"signature": null`
    # or an object here must fail as a clean usage error (exit 2), never as a
    # TypeError traceback that a reader could mistake for a verdict.
    required = ("manifest", "signature", "server_public_key")
    for k in required:
        if k not in data:
            print(f"error: manifest missing field {k!r}", file=sys.stderr)
            return 2
        if not isinstance(data[k], str):
            print(
                f"error: manifest field {k!r} must be a string, got "
                f"{type(data[k]).__name__}",
                file=sys.stderr,
            )
            return 2

    # Convenience: when verifying an offline bundle and --events was not given,
    # look for the sibling <recording_id>.events.jsonl the bundle ships next to
    # manifest.json.  If it is not found, the sealed-log check fails explicitly
    # (see _verify) rather than silently passing.
    events_arg = args.events
    if args.manifest and not events_arg:
        try:
            inner = json.loads(data.get("manifest", "{}"))
            rid = inner.get("recording_id")
            if rid:
                sibling = Path(args.manifest).resolve().parent / f"{rid}.events.jsonl"
                if sibling.exists():
                    events_arg = str(sibling)
        except Exception:
            pass

    trusted_keys = list(FATO_SERVER_PUBKEYS)
    if args.server_pubkey:
        trusted_keys.extend(args.server_pubkey)

    # The signed client metadata frame, when the expert has it: an explicit
    # --client-metadata file, otherwise a copy shipped inside the bundle.
    client_metadata = None
    if args.client_metadata:
        try:
            with open(args.client_metadata, "r", encoding="utf-8") as f:
                client_metadata = json.load(f)
        except (OSError, ValueError) as exc:
            print(f"error reading --client-metadata: {exc}", file=sys.stderr)
            return 2
    else:
        for key in ("signed_metadata", "client_metadata"):
            if isinstance(data.get(key), dict):
                client_metadata = data[key]
                break

    result = {
        "_input": {
            **data,
            "file": args.file,
            "events": events_arg,
            "client_metadata": client_metadata,
            "_trusted_server_pubkeys": trusted_keys,
        },
        "checks": {},
    }
    ok = _verify(result)
    unverified = [n for n, d in result["checks"].items() if d.get("unverified")]
    not_applicable = [
        n for n, d in result["checks"].items() if d.get("not_applicable")
    ]

    if not ok:
        verdict, exit_code = "FAIL", 1
    elif unverified:
        verdict, exit_code = "INCOMPLETE", EXIT_INCOMPLETE
    else:
        verdict, exit_code = "PASS", 0

    # When the server signature does not verify, the manifest is just a file
    # someone handed us: every value read out of it — the hashes, the tamper
    # flag, the chain comparison — is unauthenticated. Ticks against those
    # fields are still printed, so say plainly what they are worth.
    manifest_authenticated = bool(
        result["checks"].get("server_signature", {}).get("pass")
    )

    if args.json:
        # Drop _input from output to keep it compact
        out = {
            "verdict": verdict,
            "pass": ok and not unverified,
            "every_check_that_ran_passed": ok,
            "manifest_authenticated": manifest_authenticated,
            "unverified": unverified,
            "not_applicable": not_applicable,
            "checks": result["checks"],
        }
        print(json.dumps(out, indent=2, sort_keys=True))
    else:
        banner = verdict
        print(f"=== {banner} — chain-of-custody verification ===")
        for name, detail in result["checks"].items():
            if detail.get("not_applicable"):
                mark = "—"
            elif detail.get("unverified"):
                mark = "?"
            elif detail.get("pass"):
                mark = "✓"
            else:
                mark = "✗"
            extra = ""
            if detail.get("not_applicable"):
                extra = f"  {detail.get('note', 'NOT APPLICABLE')}"
            elif detail.get("unverified"):
                # `reason` as a fallback, not because anything writes it today
                # — the check that did was corrected to `note` — but because
                # the failure mode when a check attaches its message under a
                # key the renderer does not read is SILENCE. The reader sees
                # "UNVERIFIED" with no explanation and no way to know one was
                # written. This happened, and it hid the whole point of the
                # format-version check from everyone not passing --json.
                extra = (
                    f"  {detail.get('note') or detail.get('reason') or 'UNVERIFIED'}"
                )
            elif not detail.get("pass"):
                if "error" in detail:
                    extra = f"  error: {detail['error']}"
                elif "manifest" in detail and "computed" in detail:
                    extra = f"\n    manifest: {detail['manifest']}\n    computed: {detail['computed']}"
            elif detail.get("note"):
                extra = f"  ({detail['note']})"
            print(f"  {mark} {name}{extra}")
            if detail.get("key_fingerprint"):
                print(f"      key sha256: {detail['key_fingerprint']}")
        if not manifest_authenticated:
            print(
                "\n  !! The server signature did NOT verify, so this manifest is "
                "unauthenticated.\n     Every value above was read out of it and "
                "proves nothing on its own —\n     including any check marked with "
                "a tick."
            )
        if ok and unverified:
            # "with the artifacts supplied" is the usual cause and the wrong
            # one when the verifier is older than the seal: it sends a
            # court-appointed expert hunting for files they were never given,
            # instead of to the download that would fix it.
            stale_verifier = (
                result["checks"].get("manifest_version", {}).get("unverified")
            )
            skipped = (
                f"{len(unverified)} link(s) could not be verified because this "
                "verifier is older than the format this bundle was sealed in"
                if stale_verifier
                else f"{len(unverified)} link(s) could not be verified with the "
                "artifacts supplied"
            )
            if not_applicable:
                skipped += (
                    f", and {len(not_applicable)} do(es) not apply to this "
                    "recording"
                )
            print(
                f"\n  Every check that ran passed, but {skipped}. This is NOT "
                "a complete chain-of-custody verification."
            )

    return exit_code


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