#!/usr/bin/env python3
"""
Donjon Platform - Intel Pipeline: snapshot -> sign -> verify -> publish-prep

Turns the founder-manual "build a bundle when I remember to" step into a
repeatable, signed, verifiable system.  This is the SIGNING side; it is the
counterpart to lib/intel_snapshot.py (the product-side verifier).

Stages
------
  snapshot   Package the real intel DBs (data/vuln_db/vuln_intel.db +
             data/intel_feeds.db + data/threat_intel/cisa_kev.json) into a
             dated, versioned snapshot with a manifest carrying:
               - per-source provenance (authority, url, license, record count)
               - per-file sha256 + size + mtime
               - generated_at, snapshot_version, platform_version
               - per-source age_hours (so the product can enforce KEV<=48h /
                 NVD<=24h freshness on load)
  sign       Sign the manifest's canonical payload with Ed25519 (+ ML-DSA-65
             when available), using the SAME canonicalisation + dual-sig
             scheme as lib/licensing.py.  This is what makes "signed CVE
             distro" TRUE (was SHA-256-only before).
  verify     Re-verify a signed manifest (signature + on-disk hashes +
             freshness SLA) via lib/intel_snapshot.verify_snapshot.
  build      snapshot + sign in one shot; writes the signed manifest beside
             the DBs (data/intel-snapshot-manifest.json) so the product load
             path picks it up, AND packages a dated tar.gz for publish.
  publish-prep  Stage the signed bundle + manifest into a publish/ dir and
             print the exact upload command — does NOT deploy (HARD RULE).

KEYS (production)
-----------------
Ed25519  : keys/donjon-private-classical.pem  (PKCS8 PEM)
ML-DSA-65: keys/donjon-private-pqc.bin         (raw 4032-byte secret key)
mirrored in donjonsec-infrastructure/keys and CT100 /opt/donjonsec-keys.
NEVER generate new keys — that orphans every paid license.  Pass the key
paths explicitly with --classical-key / --pqc-key, or set
DONJON_INTEL_CLASSICAL_KEY / DONJON_INTEL_PQC_KEY.  For TESTS use a throwaway
keypair (see --gen-throwaway-keys).

Usage
-----
  python bin/intel-pipeline.py snapshot  [--data-dir DIR] [--out FILE]
  python bin/intel-pipeline.py sign      --manifest FILE --classical-key K [--pqc-key K]
  python bin/intel-pipeline.py verify    --manifest FILE [--data-dir DIR] [--no-hashes]
  python bin/intel-pipeline.py build     --classical-key K [--pqc-key K] [--data-dir DIR] [--output-dir DIR]
  python bin/intel-pipeline.py publish-prep --bundle FILE [--channel NAME]
  python bin/intel-pipeline.py gen-throwaway-keys --out-dir DIR     # TESTS ONLY
"""
from __future__ import annotations

import argparse
import base64
import json
import os
import sys
import tarfile
import tempfile
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional

SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SCRIPT_DIR.parent
sys.path.insert(0, str(PROJECT_ROOT / "lib"))

import intel_snapshot  # noqa: E402  (sets up sys.path above)
from intel_snapshot import (  # noqa: E402
    SNAPSHOT_MANIFEST_NAME,
    canonical_payload,
    sha256_file,
    verify_snapshot,
)

SNAPSHOT_VERSION = "1.0"
PLATFORM_VERSION = "7.5.0"

# Default publish channel — coordinated with the release track (releases via
# OCI Always Free host). Override with --channel.
DEFAULT_CHANNEL = "releases.donjonsec.com"

# Authoritative source registry (mirrors tools/build-intel-bundle.py).
INTEL_SOURCES: Dict[str, Dict[str, Any]] = {
    "nvd_cves": {
        "authority": "National Institute of Standards and Technology (NIST)",
        "url": "https://services.nvd.nist.gov/rest/json/cves/2.0/",
        "license": "Public Domain (US Government Work)",
        "description": "National Vulnerability Database — CVE records with CVSS, CWE, references",
        "data_file": "vuln_db/vuln_intel.db",
        "table": "nvd_cves",
        "mtime_table": "nvd_cves",
    },
    "cisa_kev": {
        "authority": "Cybersecurity and Infrastructure Security Agency (CISA)",
        "url": "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
        "license": "Public Domain (US Government Work)",
        "description": "Known Exploited Vulnerabilities catalog",
        "data_file": "threat_intel/cisa_kev.json",
    },
    "epss_scores": {
        "authority": "Forum of Incident Response and Security Teams (FIRST.org)",
        "url": "https://api.first.org/data/v1/epss",
        "license": "CC BY-SA 4.0",
        "description": "Exploit Prediction Scoring System",
        "data_file": "vuln_db/vuln_intel.db",
        "table": "epss_scores",
    },
    "exploit_refs": {
        "authority": "OffSec (Offensive Security)",
        "url": "https://gitlab.com/exploit-database/exploitdb",
        "license": "GPL-2.0",
        "description": "ExploitDB — public exploits mapped to CVEs",
        "data_file": "intel_feeds.db",
        "table": "exploitdb",
    },
    "osv": {
        "authority": "Google Open Source Security",
        "url": "https://api.osv.dev/v1/",
        "license": "CC BY 4.0",
        "description": "Open Source Vulnerabilities",
        "data_file": "intel_feeds.db",
        "table": "osv_vulns",
    },
    "github_advisories": {
        "authority": "GitHub / MITRE CVE Numbering Authority",
        "url": "https://api.github.com/advisories",
        "license": "CC BY 4.0",
        "description": "GitHub Security Advisories",
        "data_file": "intel_feeds.db",
        "table": "github_advisories",
    },
    "cisa_alerts": {
        "authority": "Cybersecurity and Infrastructure Security Agency (CISA)",
        "url": "https://www.cisa.gov/cybersecurity-advisories/all.xml",
        "license": "Public Domain (US Government Work)",
        "description": "CISA cybersecurity alerts and ICS-CERT advisories",
        "data_file": "intel_feeds.db",
        "table": "cisa_alerts",
    },
    "mitre_attack": {
        "authority": "MITRE Corporation",
        "url": "https://github.com/mitre/cti",
        "license": "Apache 2.0",
        "description": "MITRE ATT&CK Enterprise",
        "data_file": "intel_feeds.db",
        "table": "mitre_attack",
    },
}


# ---------------------------------------------------------------------------
# Counting helpers (stdlib sqlite3 only — air-gap safe)
# ---------------------------------------------------------------------------

def _table_count(db_path: Path, table: str) -> int:
    if not db_path.exists():
        return 0
    import sqlite3
    try:
        conn = sqlite3.connect(str(db_path))
        try:
            return conn.execute(f'SELECT count(*) FROM "{table}"').fetchone()[0]
        finally:
            conn.close()
    except Exception:
        return 0


def _kev_count(json_path: Path) -> int:
    if not json_path.exists():
        return 0
    try:
        data = json.loads(json_path.read_text("utf-8"))
        if isinstance(data, dict):
            return len(data.get("vulnerabilities", data.get("entries", [])))
        if isinstance(data, list):
            return len(data)
    except Exception:
        return 0
    return 0


def _source_age_hours(data_dir: Path, source_id: str, info: Dict[str, Any]) -> Optional[float]:
    """Best-effort age in hours for a source, from the on-disk file mtime.

    KEV/NVD ages feed the product's per-source freshness SLA (KEV<=48h,
    NVD<=24h).  mtime is the same signal api_intel.py already trusts for NVD.
    """
    fp = data_dir / info["data_file"]
    if not fp.exists():
        return None
    mtime = fp.stat().st_mtime
    return (datetime.now(timezone.utc).timestamp() - mtime) / 3600.0


# ---------------------------------------------------------------------------
# snapshot
# ---------------------------------------------------------------------------

def build_snapshot_manifest(data_dir: Path) -> Dict[str, Any]:
    """Build an UNSIGNED snapshot manifest from the real intel DBs."""
    now = datetime.now(timezone.utc).isoformat()
    manifest: Dict[str, Any] = {
        "snapshot_version": SNAPSHOT_VERSION,
        "platform_version": PLATFORM_VERSION,
        "generated_at": now,
        "created_by": "Donjon Intel Pipeline (bin/intel-pipeline.py)",
        "sources": {},
        "files": {},
        "totals": {"sources": 0, "records": 0, "files": 0, "size_bytes": 0},
    }

    seen_files: set[str] = set()
    total_records = 0

    for source_id, info in INTEL_SOURCES.items():
        data_file = info["data_file"]
        fp = data_dir / data_file

        if "table" in info and fp.suffix == ".db":
            count = _table_count(fp, info["table"])
        elif fp.suffix == ".json":
            count = _kev_count(fp)
        else:
            count = 0
        total_records += count

        src_entry: Dict[str, Any] = {
            "authority": info["authority"],
            "url": info["url"],
            "license": info["license"],
            "description": info["description"],
            "records": count,
            "data_file": data_file,
        }
        age_h = _source_age_hours(data_dir, source_id, info)
        if age_h is not None:
            src_entry["age_hours"] = round(age_h, 2)
        manifest["sources"][source_id] = src_entry

        if data_file not in seen_files and fp.exists():
            seen_files.add(data_file)
            st = fp.stat()
            manifest["files"][data_file] = {
                "size_bytes": st.st_size,
                "sha256": sha256_file(fp),
                "modified_at": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
            }

    manifest["totals"] = {
        "sources": len(manifest["sources"]),
        "records": total_records,
        "files": len(manifest["files"]),
        "size_bytes": sum(f["size_bytes"] for f in manifest["files"].values()),
    }
    return manifest


# ---------------------------------------------------------------------------
# sign
# ---------------------------------------------------------------------------

def _load_ed25519_private(path: Path):
    from cryptography.hazmat.primitives.serialization import load_pem_private_key
    return load_pem_private_key(path.read_bytes(), password=None)


def sign_manifest(
    manifest: Dict[str, Any],
    classical_key_path: Path,
    pqc_key_path: Optional[Path] = None,
) -> Dict[str, Any]:
    """Sign the manifest's canonical payload (Ed25519 + optional ML-DSA-65).

    Returns the manifest with a ``signatures`` block added.  Canonicalisation
    is IDENTICAL to lib/licensing.py / lib/intel_snapshot.canonical_payload,
    so the product verifies with the matching embedded public keys.

    PRODUCTION: pass keys/donjon-private-classical.pem and
    keys/donjon-private-pqc.bin.  TESTS: pass a throwaway keypair.
    """
    # Sign over the manifest WITHOUT any pre-existing signatures block.
    payload = canonical_payload(manifest)
    signatures: Dict[str, str] = {}

    priv = _load_ed25519_private(classical_key_path)
    classical_sig = priv.sign(payload)
    signatures["classical"] = base64.b64encode(classical_sig).decode("ascii")

    if pqc_key_path is not None:
        from dilithium_py.ml_dsa import ML_DSA_65
        if ML_DSA_65 is None:
            raise RuntimeError("dilithium-py not installed — cannot produce ML-DSA-65 signature")
        sk = pqc_key_path.read_bytes()
        pqc_sig = ML_DSA_65.sign(sk, payload)
        signatures["pqc"] = base64.b64encode(pqc_sig).decode("ascii")

    signed = dict(manifest)
    signed["signatures"] = signatures
    return signed


# ---------------------------------------------------------------------------
# sign-via-worker  (founder "Worker signs" decision)
# ---------------------------------------------------------------------------
# The OCI intel pipeline runs on a PUBLIC box that must NEVER hold the master
# private key.  Instead it POSTs the UNSIGNED manifest to the admin Worker's
# /api/intel/sign endpoint, which re-canonicalizes it (byte-identical to
# canonical_payload below) and returns {classical, pqc}.  We canonicalize on
# this side too ONLY to assert parity is even possible; the bytes we trust are
# the ones the Worker signed (its own re-canonicalization).  The product
# verifier (lib/intel_snapshot) re-canonicalizes again on load, so as long as
# all three agree (proven by the e2e test) the signature verifies.

_WORKER_SIGN_PATH = "/api/intel/sign"


def sign_manifest_via_worker(
    manifest: Dict[str, Any],
    worker_url: str,
    admin_key: str,
    timeout: float = 30.0,
) -> Dict[str, Any]:
    """POST the UNSIGNED manifest to the admin Worker and inject its signatures.

    Args:
        manifest:   the UNSIGNED manifest (no ``signatures`` block).  Any
                    pre-existing ``signatures`` is stripped before sending — the
                    Worker rejects payloads that carry one.
        worker_url: base URL (e.g. https://admin.donjonsec.com) or the full
                    endpoint URL; the path is normalised to /api/intel/sign.
        admin_key:  X-Admin-Key value (same gate as every other admin route).

    Returns the manifest with a ``signatures`` block = {"classical", "pqc"}.
    Raises RuntimeError on any non-200 / malformed response (fail closed).
    """
    if not admin_key:
        raise RuntimeError("worker admin key required (--worker-admin-key or DONJON_INTEL_WORKER_KEY)")

    # Normalise to the signing endpoint.
    base = worker_url.rstrip("/")
    if base.endswith(_WORKER_SIGN_PATH):
        endpoint = base
    else:
        endpoint = base + _WORKER_SIGN_PATH

    # Send the manifest WITHOUT signatures (the Worker re-canonicalizes itself).
    unsigned = {k: v for k, v in manifest.items() if k != "signatures"}
    body = json.dumps(unsigned).encode("utf-8")

    req = urllib.request.Request(
        endpoint,
        data=body,
        method="POST",
        headers={"Content-Type": "application/json", "X-Admin-Key": admin_key},
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8")
            status = resp.status
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", "replace")
        raise RuntimeError(f"Worker signing failed: HTTP {exc.code}: {detail}") from exc
    except urllib.error.URLError as exc:
        raise RuntimeError(f"Worker signing unreachable ({endpoint}): {exc.reason}") from exc

    try:
        result = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise RuntimeError(f"Worker returned non-JSON (HTTP {status}): {raw[:200]}") from exc

    classical = result.get("classical")
    pqc = result.get("pqc")
    if not classical or not pqc:
        raise RuntimeError(f"Worker response missing signatures: {result}")

    signed = dict(manifest)
    signed["signatures"] = {"classical": classical, "pqc": pqc}
    return signed


def _resolve_worker(args) -> Optional[tuple]:
    """Return (worker_url, admin_key) if Worker-signing is requested, else None.

    Worker-signing is activated by --sign-via-worker (or DONJON_INTEL_SIGN_VIA_WORKER).
    """
    url = getattr(args, "sign_via_worker", None) or os.environ.get("DONJON_INTEL_SIGN_VIA_WORKER")
    if not url:
        return None
    key = getattr(args, "worker_admin_key", None) or os.environ.get("DONJON_INTEL_WORKER_KEY") or ""
    return (url, key)


# ---------------------------------------------------------------------------
# build (snapshot + sign + write manifest + tar.gz)
# ---------------------------------------------------------------------------

def build_signed_bundle(
    data_dir: Path,
    output_dir: Path,
    classical_key_path: Optional[Path],
    pqc_key_path: Optional[Path],
    write_live_manifest: bool = True,
    worker: Optional[tuple] = None,
) -> Dict[str, Any]:
    output_dir.mkdir(parents=True, exist_ok=True)
    manifest = build_snapshot_manifest(data_dir)
    if worker is not None:
        worker_url, admin_key = worker
        signed = sign_manifest_via_worker(manifest, worker_url, admin_key)
    else:
        if classical_key_path is None:
            raise RuntimeError("no signing method: pass --classical-key or --sign-via-worker")
        signed = sign_manifest(manifest, classical_key_path, pqc_key_path)

    date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    bundle_path = output_dir / f"intel-snapshot-{date_str}.tar.gz"
    manifest_out = output_dir / f"intel-snapshot-manifest-{date_str}.json"
    manifest_out.write_text(json.dumps(signed, indent=2), encoding="utf-8")

    # The product load path looks for data/intel-snapshot-manifest.json.
    live_manifest = data_dir / SNAPSHOT_MANIFEST_NAME
    if write_live_manifest:
        live_manifest.write_text(json.dumps(signed, indent=2), encoding="utf-8")

    with tarfile.open(bundle_path, "w:gz") as tar:
        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".json", delete=False, dir=str(output_dir)
        ) as mf:
            json.dump(signed, mf, indent=2)
            mtmp = Path(mf.name)
        try:
            tar.add(str(mtmp), arcname=SNAPSHOT_MANIFEST_NAME)
            for data_file in signed["files"]:
                fp = data_dir / data_file
                if fp.exists():
                    tar.add(str(fp), arcname=f"data/{data_file}")
        finally:
            mtmp.unlink(missing_ok=True)

    return {
        "bundle_path": str(bundle_path),
        "manifest_path": str(manifest_out),
        "live_manifest_path": str(live_manifest) if write_live_manifest else None,
        "signed": signed,
    }


# ---------------------------------------------------------------------------
# throwaway keygen (TESTS ONLY)
# ---------------------------------------------------------------------------

def gen_throwaway_keys(out_dir: Path) -> Dict[str, str]:
    """Generate a THROWAWAY Ed25519 (+ML-DSA-65) keypair for TESTING ONLY.

    Writes private/public halves and prints the base64 public keys to paste
    into DONJON_INTEL_PUBKEY_* env vars so the verifier trusts the test signer.
    PRODUCTION NEVER USES THIS — production reuses keys/donjon-private-*.
    """
    out_dir.mkdir(parents=True, exist_ok=True)
    from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
    from cryptography.hazmat.primitives import serialization

    priv = Ed25519PrivateKey.generate()
    classical_priv = out_dir / "throwaway-classical.pem"
    classical_priv.write_bytes(
        priv.private_bytes(
            serialization.Encoding.PEM,
            serialization.PrivateFormat.PKCS8,
            serialization.NoEncryption(),
        )
    )
    pub_raw = priv.public_key().public_bytes(
        serialization.Encoding.Raw, serialization.PublicFormat.Raw
    )
    classical_pub_b64 = base64.b64encode(pub_raw).decode("ascii")

    out: Dict[str, str] = {
        "classical_key": str(classical_priv),
        "classical_pub_b64": classical_pub_b64,
    }

    try:
        from dilithium_py.ml_dsa import ML_DSA_65
        pk, sk = ML_DSA_65.keygen()
        pqc_priv = out_dir / "throwaway-pqc.bin"
        pqc_priv.write_bytes(sk)
        out["pqc_key"] = str(pqc_priv)
        out["pqc_pub_b64"] = base64.b64encode(pk).decode("ascii")
    except Exception as exc:  # pragma: no cover
        out["pqc_error"] = str(exc)

    return out


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def _resolve_key(arg: Optional[str], env: str) -> Optional[Path]:
    import os
    val = arg or os.environ.get(env)
    return Path(val) if val else None


def main() -> int:
    parser = argparse.ArgumentParser(description="Donjon Intel Pipeline (snapshot/sign/verify/publish)")
    sub = parser.add_subparsers(dest="cmd", required=True)

    default_data = str(PROJECT_ROOT / "data")
    default_out = str(PROJECT_ROOT / "data" / "bundles")

    p_snap = sub.add_parser("snapshot", help="Build unsigned snapshot manifest")
    p_snap.add_argument("--data-dir", default=default_data)
    p_snap.add_argument("--out", default=None, help="Write manifest here (default stdout)")

    p_sign = sub.add_parser("sign", help="Sign an existing manifest")
    p_sign.add_argument("--manifest", required=True)
    p_sign.add_argument("--classical-key", default=None)
    p_sign.add_argument("--pqc-key", default=None)
    p_sign.add_argument("--out", default=None)
    p_sign.add_argument("--sign-via-worker", default=None,
                        help="Admin Worker URL (e.g. https://admin.donjonsec.com) — sign remotely instead of with local keys")
    p_sign.add_argument("--worker-admin-key", default=None,
                        help="X-Admin-Key for the Worker (or DONJON_INTEL_WORKER_KEY)")

    p_ver = sub.add_parser("verify", help="Verify a signed manifest")
    p_ver.add_argument("--manifest", required=True)
    p_ver.add_argument("--data-dir", default=None)
    p_ver.add_argument("--no-hashes", action="store_true")
    p_ver.add_argument("--max-age-days", type=int, default=None)

    p_build = sub.add_parser("build", help="snapshot + sign + write live manifest + tar.gz")
    p_build.add_argument("--data-dir", default=default_data)
    p_build.add_argument("--output-dir", default=default_out)
    p_build.add_argument("--classical-key", default=None)
    p_build.add_argument("--pqc-key", default=None)
    p_build.add_argument("--no-live-manifest", action="store_true")
    p_build.add_argument("--sign-via-worker", default=None,
                         help="Admin Worker URL — sign remotely instead of with local keys")
    p_build.add_argument("--worker-admin-key", default=None,
                         help="X-Admin-Key for the Worker (or DONJON_INTEL_WORKER_KEY)")

    p_pub = sub.add_parser("publish-prep", help="Stage bundle for publish (no deploy)")
    p_pub.add_argument("--bundle", required=True)
    p_pub.add_argument("--channel", default=DEFAULT_CHANNEL)
    p_pub.add_argument("--publish-dir", default=str(PROJECT_ROOT / "data" / "publish"))

    p_gen = sub.add_parser("gen-throwaway-keys", help="TESTS ONLY: generate a throwaway keypair")
    p_gen.add_argument("--out-dir", required=True)

    args = parser.parse_args()

    if args.cmd == "snapshot":
        manifest = build_snapshot_manifest(Path(args.data_dir))
        text = json.dumps(manifest, indent=2)
        if args.out:
            Path(args.out).write_text(text, encoding="utf-8")
            print(f"Wrote unsigned manifest: {args.out}")
        else:
            print(text)
        return 0

    if args.cmd == "sign":
        manifest = json.loads(Path(args.manifest).read_text("utf-8"))
        worker = _resolve_worker(args)
        if worker is not None:
            try:
                signed = sign_manifest_via_worker(manifest, worker[0], worker[1])
            except RuntimeError as exc:
                print(f"ERROR: {exc}", file=sys.stderr)
                return 2
            sig_src = "worker"
        else:
            ck = _resolve_key(args.classical_key, "DONJON_INTEL_CLASSICAL_KEY")
            pk = _resolve_key(args.pqc_key, "DONJON_INTEL_PQC_KEY")
            if ck is None:
                print("ERROR: --classical-key (or DONJON_INTEL_CLASSICAL_KEY) or --sign-via-worker required", file=sys.stderr)
                return 2
            signed = sign_manifest(manifest, ck, pk)
            sig_src = "local-key"
        text = json.dumps(signed, indent=2)
        out = args.out or args.manifest
        Path(out).write_text(text, encoding="utf-8")
        print(f"Signed manifest -> {out} (sigs: {list(signed['signatures'].keys())}, via {sig_src})")
        return 0

    if args.cmd == "verify":
        mpath = Path(args.manifest)
        ddir = Path(args.data_dir) if args.data_dir else None
        result = verify_snapshot(
            mpath, data_dir=ddir, max_age_days=args.max_age_days,
            check_hashes=not args.no_hashes,
        )
        print(json.dumps(result.to_dict(), indent=2))
        return 0 if result.trusted else 1

    if args.cmd == "build":
        worker = _resolve_worker(args)
        ck = _resolve_key(args.classical_key, "DONJON_INTEL_CLASSICAL_KEY")
        pk = _resolve_key(args.pqc_key, "DONJON_INTEL_PQC_KEY")
        if worker is None and ck is None:
            print("ERROR: --classical-key (or DONJON_INTEL_CLASSICAL_KEY) or --sign-via-worker required", file=sys.stderr)
            return 2
        try:
            res = build_signed_bundle(
                Path(args.data_dir), Path(args.output_dir), ck, pk,
                write_live_manifest=not args.no_live_manifest,
                worker=worker,
            )
        except RuntimeError as exc:
            print(f"ERROR: {exc}", file=sys.stderr)
            return 2
        print(f"Bundle:        {res['bundle_path']}")
        print(f"Manifest:      {res['manifest_path']}")
        print(f"Live manifest: {res['live_manifest_path']}")
        print(f"Signatures:    {list(res['signed']['signatures'].keys())}")
        print(f"Records:       {res['signed']['totals']['records']:,}")
        return 0

    if args.cmd == "publish-prep":
        bundle = Path(args.bundle)
        if not bundle.exists():
            print(f"ERROR: bundle not found: {bundle}", file=sys.stderr)
            return 2
        pub_dir = Path(args.publish_dir)
        pub_dir.mkdir(parents=True, exist_ok=True)
        import shutil
        staged = pub_dir / bundle.name
        shutil.copy2(bundle, staged)
        print(f"Staged for publish: {staged}")
        print(f"Channel: {args.channel}")
        print("NEXT (requires Cris — NOT run here):")
        print(f"  scp {staged} oci-releases:/var/www/{args.channel}/intel/")
        print("  (or rclone/forgejo release upload — see deploy/intel-pipeline.timer design)")
        return 0

    if args.cmd == "gen-throwaway-keys":
        out = gen_throwaway_keys(Path(args.out_dir))
        print(json.dumps(out, indent=2))
        return 0

    return 1


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