"""Third-party receipt verification (NF-4) — the consumer-side half of
the receipt chain (tests/test_apigen.py asserts the producer side).

Validates an archived Statbook API response against the public release
manifest with no cooperation from Statbook:

  1. the response's `figure` object re-serializes canonically to the
     SHA-256 the receipt claims (record_hash);
  2. that hash is listed in the manifest under the figure's id;
  3. the manifest's release_version matches the receipt's;
  4. optionally, the manifest's minisign signature verifies against the
     published public key (requires the `minisign` binary).

Responses without the full `figure` object (current/asof/history shapes)
verify partially: their receipt names the manifest entry, but only the
full record at receipt.record_url can be re-hashed — the report says so
rather than claiming more than it checked.

Usage:
  python -m pipeline.verify_receipt response.json --manifest manifest.json
      [--pubkey minisign.pub --signature manifest.json.minisig]

Exit codes: 0 verified · 1 verification FAILED · 2 usage/input error.

This module is deliberately self-contained (stdlib + one local import
boundary): a consumer can vendor this single file next to an archived
response and run it years later.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import shutil
import subprocess
import tempfile
from dataclasses import dataclass, field
from pathlib import Path


def _canonical_bytes(obj: dict) -> bytes:
    return (json.dumps(obj, ensure_ascii=False, indent=2) + "\n").encode("utf-8")


def canonical_record_hash(record: dict) -> str:
    """The corpus/release serialization convention (ADR-0004, stated in
    every receipt's record_hash_of): 2-space indent, non-ASCII preserved,
    trailing newline, UTF-8."""
    data = (json.dumps(record, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
    return "sha256:" + hashlib.sha256(data).hexdigest()


@dataclass
class Verification:
    checks: list[str] = field(default_factory=list)  # what passed
    notes: list[str] = field(default_factory=list)  # honest partiality
    violations: list[str] = field(default_factory=list)  # what failed

    @property
    def ok(self) -> bool:
        return not self.violations


def verify_response(response: dict, manifest: dict) -> Verification:
    v = Verification()
    receipt = response.get("receipt")
    if not receipt:
        v.violations.append("response carries no receipt block")
        return v

    claimed = receipt.get("record_hash", "")
    fid = (
        response.get("figure", {}).get("id")
        or response.get("figure_id")
        or "?"
    )

    # 1 — recompute from the served figure object, when present
    figure = response.get("figure")
    if figure is not None:
        recomputed = canonical_record_hash(figure)
        if recomputed == claimed:
            v.checks.append(
                f"figure object re-hashes to the receipt's record_hash ({claimed[:20]}…)"
            )
        else:
            v.violations.append(
                f"figure object hashes to {recomputed} but the receipt "
                f"claims {claimed} — the response content does not match "
                "its own receipt"
            )
    else:
        v.notes.append(
            "response has no full `figure` object (current/asof/history "
            f"shape): record_hash not recomputable here — fetch "
            f"{receipt.get('record_url', 'the full record')} to verify "
            "content, or compare the value fields against it"
        )

    # 2 — the manifest lists exactly that hash for this figure
    manifest_hash = manifest.get("record_hashes", {}).get(fid)
    if manifest_hash is None:
        v.violations.append(f"manifest has no record_hashes entry for {fid!r}")
    elif manifest_hash == claimed:
        v.checks.append(f"manifest record_hashes[{fid!r}] matches the receipt")
    else:
        v.violations.append(
            f"manifest lists {manifest_hash} for {fid!r} but the receipt "
            f"claims {claimed}"
        )

    # 3 — same release
    mv, rv = manifest.get("release_version"), receipt.get("release_version")
    if mv == rv:
        v.checks.append(f"release_version matches ({mv})")
    else:
        v.violations.append(
            f"receipt is from release {rv} but the manifest is {mv} — "
            "verify against that release's manifest instead"
        )
    return v


def verify_supplement(
    supplement: dict, pubkey: Path, manifest: dict | None = None
) -> Verification:
    """The paid-tier half of NF-4 (ADR-0010 §1): a future-value supplement
    verifies from its OWN embedded signature against the published key — no
    manifest lookup for the value. Currency is checked separately against the
    manifest's value-blind serial index (a superseded serial is caught)."""
    v = Verification()
    payload = supplement.get("payload")
    if payload is None:
        v.violations.append("supplement has no payload block")
        return v
    fid = payload.get("figure_id", "?")
    sig = supplement.get("signature")

    # 1 — the signature covers canonical(payload) ONLY (worker envelope/meta
    # never enter the signed bytes; the verifier reconstructs, never trusts
    # the served bytes)
    if not sig:
        v.notes.append("supplement is unsigned (dev build) — signature not checked")
    elif shutil.which("minisign") is None:
        v.notes.append("minisign not installed — supplement signature not checked")
    else:
        with tempfile.TemporaryDirectory() as td:
            blob = Path(td) / "payload.json"
            blob.write_bytes(_canonical_bytes(payload))
            sigf = Path(td) / "payload.json.minisig"
            sigf.write_text(sig, encoding="utf-8")
            proc = subprocess.run(
                ["minisign", "-V", "-p", str(pubkey), "-m", str(blob), "-x", str(sigf)],
                capture_output=True, text=True,
            )
            if proc.returncode == 0:
                v.checks.append(f"supplement payload signature verifies for {fid!r}")
            else:
                v.violations.append(
                    f"supplement signature FAILED: {proc.stderr.strip() or proc.stdout.strip()}"
                )

    # 2 — binding: the payload commits to the public record's hash (§13.1).
    if manifest is not None:
        rh = payload.get("record_hash")
        manifest_rh = manifest.get("record_hashes", {}).get(fid)
        if manifest_rh is None:
            v.notes.append(f"manifest has no record_hashes entry for {fid!r}")
        elif rh == manifest_rh:
            v.checks.append("supplement record_hash binds to the public record in this manifest")
        else:
            v.violations.append(
                f"supplement record_hash {rh} does not match the manifest's "
                f"{manifest_rh} for {fid!r} — supplement/record mismatch"
            )

    # 3 — currency: is this serial still the live one per the public index?
    if manifest is not None:
        idx = {e["figure_id"]: e for e in manifest.get("supplement_index", [])}
        entry = idx.get(fid)
        serial = payload.get("supplement_serial")
        if entry is None:
            v.notes.append(f"no supplement_index entry for {fid!r} in this manifest — currency unchecked")
        elif entry.get("supplement_serial") == serial:
            v.checks.append(f"supplement serial {serial} is current per the manifest index")
        elif entry.get("supplement_serial", 0) > (serial or 0):
            v.violations.append(
                f"supplement serial {serial} is SUPERSEDED — the manifest index "
                f"shows serial {entry.get('supplement_serial')} for {fid!r}"
            )
        else:
            v.notes.append("supplement serial is newer than this manifest (later release)")
    return v


def verify_sources(response: dict, sources_dir: Path) -> Verification:
    """The audit-bundle half (PHASE3 WS-7): every file under sources/ is named
    by the SHA-256 of its own bytes, so it must hash back to its filename — a
    tamper-evident naming scheme needing no signature. Cited receipt sources
    that are present are confirmed against the receipt; ones that are absent
    are noted (non-OGL sources ship as hash+URI only, bytes omitted)."""
    v = Verification()
    if not sources_dir.is_dir():
        v.violations.append(f"--sources-dir {sources_dir} is not a directory")
        return v
    on_disk: dict[str, str] = {}
    for f in sorted(p for p in sources_dir.iterdir() if p.is_file()):
        if f.name == "index.json":
            continue
        digest = "sha256:" + hashlib.sha256(f.read_bytes()).hexdigest()
        on_disk[f.name] = digest
        if digest.split(":", 1)[1] != f.name:
            v.violations.append(
                f"sources/{f.name} does not hash to its own filename "
                f"({digest}) — the bundled source bytes are tampered"
            )
        else:
            v.checks.append(f"sources/{f.name[:16]}… hashes to its own name")
    receipt = response.get("receipt") or {}
    for s in receipt.get("sources", []):
        ch = s.get("content_hash")
        if not ch:
            continue
        hexn = ch.split(":", 1)[-1]
        if hexn in on_disk:
            v.checks.append(
                f"cited source {hexn[:16]}… is bundled and matches the receipt"
            )
        else:
            v.notes.append(
                f"cited source {hexn[:16]}… is not bundled (omitted — non-OGL "
                "or unavailable; see sources/index.json)"
            )
    return v


def verify_signature(
    manifest_path: Path, pubkey: Path, signature: Path | None = None
) -> Verification:
    """minisign detached-signature check (ADR-0007). Separate from
    verify_response so offline consumers without minisign still get the
    hash chain."""
    v = Verification()
    signature = signature or Path(str(manifest_path) + ".minisig")
    if shutil.which("minisign") is None:
        v.violations.append("minisign binary not installed — cannot verify signature")
        return v
    if not signature.exists():
        v.violations.append(f"no signature file at {signature}")
        return v
    proc = subprocess.run(
        ["minisign", "-V", "-p", str(pubkey), "-m", str(manifest_path),
         "-x", str(signature)],
        capture_output=True,
        text=True,
    )
    if proc.returncode == 0:
        v.checks.append("manifest minisign signature verifies")
    else:
        v.violations.append(
            f"manifest signature FAILED: {proc.stderr.strip() or proc.stdout.strip()}"
        )
    return v


def main(argv=None) -> int:
    p = argparse.ArgumentParser(
        description="Verify an archived Statbook API response against the "
        "public release manifest (NF-4)."
    )
    p.add_argument("response", help="archived API response JSON file (or a "
                   "supplement blob with --supplement)")
    p.add_argument("--manifest", help="release manifest.json (required unless "
                   "--supplement is given without a manifest)")
    p.add_argument("--supplement", action="store_true",
                   help="the input is a paid future-value supplement; verify "
                   "its embedded signature against --pubkey and, with "
                   "--manifest, its record_hash binding + serial currency")
    p.add_argument("--pubkey", help="minisign public key (required for --supplement)")
    p.add_argument("--signature", help="manifest .minisig (default: <manifest>.minisig)")
    p.add_argument("--sources-dir", help="an audit bundle's sources/ directory; "
                   "hash each file against its own name and the receipt's cited "
                   "source hashes")
    args = p.parse_args(argv)

    try:
        doc = json.loads(Path(args.response).read_text(encoding="utf-8"))
        manifest = (
            json.loads(Path(args.manifest).read_text(encoding="utf-8"))
            if args.manifest else None
        )
    except (OSError, json.JSONDecodeError) as e:
        print(f"cannot read inputs: {e}")
        return 2

    if args.supplement:
        if not args.pubkey:
            print("--supplement requires --pubkey to verify the signature")
            return 2
        v = verify_supplement(doc, Path(args.pubkey), manifest)
        for line in v.checks:
            print(f"  OK   {line}")
        for line in v.notes:
            print(f"  NOTE {line}")
        for line in v.violations:
            print(f"  FAIL {line}")
        print("VERIFIED" if v.ok else "VERIFICATION FAILED")
        return 0 if v.ok else 1

    if manifest is None:
        print("--manifest is required to verify a response receipt")
        return 2
    response = doc
    v = verify_response(response, manifest)
    if args.pubkey:
        sig = verify_signature(
            Path(args.manifest),
            Path(args.pubkey),
            Path(args.signature) if args.signature else None,
        )
        v.checks += sig.checks
        v.violations += sig.violations
    else:
        v.notes.append(
            "signature not checked (no --pubkey): pair this with the "
            "manifest copy in the public statbook-releases repo for "
            "tamper-evidence"
        )

    if args.sources_dir:
        sv = verify_sources(response, Path(args.sources_dir))
        v.checks += sv.checks
        v.notes += sv.notes
        v.violations += sv.violations

    for line in v.checks:
        print(f"  OK   {line}")
    for line in v.notes:
        print(f"  NOTE {line}")
    for line in v.violations:
        print(f"  FAIL {line}")
    print("VERIFIED" if v.ok else "VERIFICATION FAILED")
    return 0 if v.ok else 1


if __name__ == "__main__":
    raise SystemExit(main())
