"""abom — the Agent Bill of Materials CLI.

    abom scan .                 # detect components, emit a signed ABOM
    abom verify abom.json       # check signature (and policy, if given)
    abom gate abom.json -t TOOL # ALLOW/DENY one action (deny-by-default, notarized)
    abom broker abom.json -u …  # run the MCP broker: mediate an agent's tool calls
    abom witness gate.mlog       # cosign the log with an independent witness key
    abom anchor gate.mlog        # RFC 3161 timestamp the log root (third-party proof)
    abom keygen                 # show the local ed25519 signing key
    abom version

Exit code is non-zero when verification finds violations, so it drops into CI.
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

import typer

from . import __version__, bom, log, scan as scanner, sign, viz as _viz

app = typer.Typer(
    add_completion=False,
    help="ABOM — know what your AI agents are made of, and prove it.",
)


@app.callback(invoke_without_command=True)
def _main(
    ctx: typer.Context,
    no_banner: bool = typer.Option(
        False, "--no-banner", help="Suppress the ABOM banner.", is_eager=True
    ),
):
    """Show the ABOM banner before any command (TTY-aware, pipe-safe).

    Suppressed by --no-banner, by --quiet/--json-logs on a subcommand, or when
    the ABOM_NO_BANNER env var is set. Skipped automatically when stdout isn't a
    terminal, so it never pollutes piped output or CI logs.
    """
    import os

    quiet_flags = {"-q", "--quiet", "--json-logs"}
    # also suppress when JSON is being piped to stdout (scan -o -), so the banner
    # never contaminates machine-readable output.
    stdout_is_data = "-" in sys.argv and ("-o" in sys.argv or "--output" in sys.argv)
    suppress = (
        no_banner
        or os.environ.get("ABOM_NO_BANNER")
        or quiet_flags.intersection(sys.argv)
        or stdout_is_data
    )
    if not suppress:
        # banner goes to STDERR so it never pollutes piped stdout data
        typer.secho(_viz.banner(__version__), fg="cyan", err=True)

    # No subcommand given → show the banner (above) then the help screen.
    if ctx.invoked_subcommand is None:
        typer.echo(ctx.get_help())
        raise typer.Exit(0)


TYPE_LABEL = {
    "model": "models", "tool": "tools", "prompt": "prompts", "dataSource": "data sources",
    "policy": "policies / guardrails", "framework": "frameworks", "mcpServer": "MCP servers",
}


@app.command()
def scan(
    path: str = typer.Argument(".", help="Repository or directory to scan."),
    url: str = typer.Option(None, "--url", help="Scan a remote git repo URL (clones it, scans, cleans up)."),
    ref: str = typer.Option(None, "--ref", help="Git branch/tag to clone when using --url."),
    mcp: str = typer.Option(None, "--mcp", help="Scan a live MCP server endpoint (enumerates its declared tools/resources/prompts)."),
    mcp_token: str = typer.Option(None, "--token", help="Bearer token for an auth-gated MCP server (or set ABOM_MCP_TOKEN)."),
    mcp_header: list[str] = typer.Option(None, "--header", help="Extra HTTP header for --mcp, 'Key: Value' (repeatable)."),
    output: str = typer.Option("abom.json", "-o", "--output", help="Output file ('-' for stdout)."),
    sign_manifest: bool = typer.Option(True, "--sign/--no-sign", help="ed25519-sign the manifest."),
    name: str = typer.Option(None, "--name", help="Override the detected agent name."),
    version: str = typer.Option(None, "--agent-version", help="Override the detected agent version."),
    card: str = typer.Option(None, "--card", help="Also write an SVG summary card to this path."),
    verbose: int = typer.Option(0, "-v", "--verbose", count=True, help="-v info, -vv debug (to stderr)."),
    quiet: bool = typer.Option(False, "-q", "--quiet", help="Errors only."),
    json_logs: bool = typer.Option(False, "--json-logs", help="Structured NDJSON logs to stderr (CI)."),
):
    """Scan a repo and emit a signed ABOM Composition Manifest.

    Scans a local PATH by default, or a remote git repo with --url
    (e.g. --url https://github.com/org/agent). The remote is shallow-cloned to a
    temp dir, scanned, and cleaned up — no code from it is ever executed.
    """
    log.setup(verbose, quiet, json_logs)

    from . import remote as _remote

    if mcp:
        if not _remote.is_mcp_url(mcp):
            typer.secho(f"not a valid MCP endpoint URL (need http/https): {mcp}", fg="red", err=True)
            raise typer.Exit(2)
        import os as _os
        headers: dict[str, str] = {}
        for h in (mcp_header or []):
            if ":" not in h:
                typer.secho(f"bad --header (need 'Key: Value'): {h}", fg="red", err=True)
                raise typer.Exit(2)
            k, v = h.split(":", 1)
            headers[k.strip()] = v.strip()
        token = mcp_token or _os.environ.get("ABOM_MCP_TOKEN")
        if token and "Authorization" not in headers:
            headers["Authorization"] = f"Bearer {token}"
        try:
            caps = _remote.fetch_mcp(mcp, headers=headers or None)
        except RuntimeError as exc:
            typer.secho(str(exc), fg="red", err=True)
            raise typer.Exit(2)
        result = _remote.mcp_to_scan_result(mcp, caps)
    elif url:
        if not _remote.is_git_url(url):
            typer.secho(f"not a recognized git URL: {url}", fg="red", err=True)
            raise typer.Exit(2)
        ctx = _remote.fetch_git(url, ref=ref)
        default_name = url.rstrip("/").split("/")[-1].removesuffix(".git")
        try:
            with ctx as root:
                result = scanner.scan(Path(root))
        except RuntimeError as exc:
            typer.secho(str(exc), fg="red", err=True)
            raise typer.Exit(2)
        if result["agent"].get("name") in (None, "repo", "0.0.0") and default_name:
            result["agent"]["name"] = default_name
    else:
        root = Path(path)
        if not root.exists():
            typer.secho(f"path not found: {path}", fg="red", err=True)
            raise typer.Exit(2)
        result = scanner.scan(root)
    if name:
        result["agent"]["name"] = name
    if version:
        result["agent"]["version"] = version
    manifest = bom.build_composition(
        result["agent"], result["components"], result["controls"], sign=sign_manifest
    )

    text = json.dumps(manifest, indent=2)
    if output == "-":
        typer.echo(text)
    else:
        Path(output).write_text(text)

    # When JSON goes to stdout (-o -), the human summary goes to stderr so the
    # piped output stays pure JSON.
    to_err = output == "-"

    a = manifest["agent"]
    comps = manifest["components"]
    typer.secho(f"\n  ABOM · {a['name']} @ {a['version']}  —  {len(comps)} components",
                fg="cyan", bold=True, err=to_err)
    typer.echo(_viz.findings_table(comps), err=to_err)
    sig = manifest.get("signature", {})
    if sig:
        typer.secho(f"  signed: {sig.get('alg')} · key {sig.get('key_id', '?')}", fg="green", err=to_err)
    typer.secho(f"  composition_sha256: {manifest['composition_sha256'][:16]}…", err=to_err)
    if output != "-":
        typer.secho(f"  → wrote {output}", fg="cyan")
    if card:
        Path(card).write_text(_viz.scan_card(manifest))
        typer.secho(f"  → wrote card {card}", fg="cyan", err=to_err)
    if output != "-":
        typer.echo("")


@app.command()
def verify(
    abom_file: str = typer.Argument("abom.json", help="ABOM file to verify."),
    policy_file: str = typer.Option(None, "--policy", "-p", help="Policy JSON to enforce."),
    run: str = typer.Option(
        None, "--run", "-r",
        help="Also verify a persisted Action Provenance run (a run id from "
             "`abom runs list`, or a path to a .jsonl chain).",
    ),
    trusted_key: list[str] = typer.Option(
        None, "--trusted-key", "-k",
        help="Pin an authorized signer key_id (repeatable). When set, a manifest "
             "signed by any other key fails verification — this is what makes "
             "'signed' authenticate the signer, not just integrity.",
    ),
    verbose: int = typer.Option(0, "-v", "--verbose", count=True, help="-v info, -vv debug (to stderr)."),
    quiet: bool = typer.Option(False, "-q", "--quiet", help="Errors only."),
    json_logs: bool = typer.Option(False, "--json-logs", help="Structured NDJSON logs to stderr (CI)."),
):
    """Verify an ABOM's signature, its runtime provenance (--run), and policy (--policy)."""
    log.setup(verbose, quiet, json_logs)
    try:
        doc = json.loads(Path(abom_file).read_text())
    except Exception as exc:
        typer.secho(f"could not read {abom_file}: {exc}", fg="red", err=True)
        raise typer.Exit(2)
    if doc.get("type") != "CompositionManifest":
        typer.secho("not a Composition Manifest (verify expects `abom scan` output)", fg="red", err=True)
        raise typer.Exit(2)

    chain: list = []
    if run:
        from .adapters import sink as _sink

        try:
            chain = _sink.load_chain(_sink.resolve_run(run))
        except (FileNotFoundError, ValueError) as exc:
            typer.secho(f"could not load run: {exc}", fg="red", err=True)
            raise typer.Exit(2)

    policy = json.loads(Path(policy_file).read_text()) if policy_file else None
    pinned = set(trusted_key) if trusted_key else None
    result = bom.verify_abom(doc, chain, policy, trusted_keys=pinned)

    if result["ok"]:
        typer.secho(
            f"\n  ✓ VALID — signature OK, {result['components']} components"
            + (f", {result['actions']} action(s) chain-verified" if run else "")
            + (", policy clean" if policy else "") + "\n",
            fg="green", bold=True,
        )
        raise typer.Exit(0)

    typer.secho(f"\n  ✗ {len(result['findings'])} finding(s):", fg="red", bold=True)
    for f in result["findings"]:
        loc = f.get("component") or (f"seq {f['seq']}" if "seq" in f else "-")
        typer.echo(f"      • [{f['severity']}] {f['rule']} ({loc}): {f['detail']}")
    typer.echo("")
    raise typer.Exit(1)


runs_app = typer.Typer(
    add_completion=False,
    help="Inspect persisted Action Provenance runs (written by instrument(sink=…)).",
)
app.add_typer(runs_app, name="runs")


@runs_app.command("list")
def runs_list(
    runs_dir: str = typer.Option(
        None, "--dir", "-d", help="Runs directory (default .abom/runs)."
    ),
):
    """List persisted runs: id, agent, records, span, and chain validity."""
    from .adapters import sink as _sink

    rows = _sink.list_runs(runs_dir or _sink.DEFAULT_RUNS_DIR)
    if not rows:
        typer.secho("  no persisted runs found — record one with instrument(sink='auto')",
                    fg="bright_black")
        raise typer.Exit(0)
    typer.secho(f"  {'RUN':44} {'AGENT':20} {'RECORDS':>7}  {'CHAIN':5}  STARTED", bold=True)
    for r in rows:
        chain = typer.style("ok", fg="green") if r["valid"] else typer.style("BROKEN", fg="red")
        typer.echo(f"  {r['run']:44} {r['agent_ref']:20} {r['records']:>7}  {chain:14}  {r['started_at'][:19]}")


@runs_app.command("show")
def runs_show(
    run: str = typer.Argument(..., help="Run id (from `abom runs list`) or path to a .jsonl chain."),
    runs_dir: str = typer.Option(
        None, "--dir", "-d", help="Runs directory (default .abom/runs)."
    ),
):
    """Show a persisted run record-by-record and verify its hash chain."""
    from .adapters import sink as _sink
    from .audit import verify_chain

    try:
        path = _sink.resolve_run(run, runs_dir or _sink.DEFAULT_RUNS_DIR)
        chain = _sink.load_chain(path)
    except (FileNotFoundError, ValueError) as exc:
        typer.secho(f"  {exc}", fg="red", err=True)
        raise typer.Exit(2)

    typer.secho(f"  run {path.stem}  ({len(chain)} record(s))", bold=True)
    for rec in chain:
        d = rec.get("data", {})
        models = ", ".join(m.get("model", "?") for m in d.get("model_calls", [])) or "-"
        tools = ", ".join(t.get("name", "?") for t in d.get("tools_invoked", [])) or "-"
        typer.echo(f"    seq {rec.get('seq'):>3}  {rec.get('created_at', '')[:19]}  "
                   f"decision={d.get('decision', '?')}  models=[{models}]  tools=[{tools}]")

    v = verify_chain(chain)
    if v["valid"]:
        typer.secho(f"\n  ✓ chain intact — {len(chain)} record(s), hashes verified\n",
                    fg="green", bold=True)
        raise typer.Exit(0)
    typer.secho(f"\n  ✗ chain BROKEN at seq {v['broken_seq']} ({v['reason']})\n",
                fg="red", bold=True)
    raise typer.Exit(1)


@app.command()
def keygen(
    show_private: bool = typer.Option(False, "--show-private", help="Print the private key path."),
    verbose: int = typer.Option(0, "-v", "--verbose", count=True, help="-v info, -vv debug (to stderr)."),
    quiet: bool = typer.Option(False, "-q", "--quiet", help="Errors only."),
    json_logs: bool = typer.Option(False, "--json-logs", help="Structured NDJSON logs to stderr (CI)."),
):
    """Show (or create) the local ed25519 signing key."""
    log.setup(verbose, quiet, json_logs)
    key = sign.load_or_create_key()
    pub_b64 = sign._pub_b64(key.public_key())
    path = sign.default_key_path()
    typer.secho("  ABOM signing key", fg="cyan", bold=True)
    typer.echo(f"  key_id:     {sign.key_id(pub_b64)}")
    typer.echo(f"  public_key: {pub_b64}")
    if show_private:
        typer.echo(f"  private:    {path}")
    typer.secho(f"  stored at {path} (override with ABOM_KEY)", fg="bright_black")


@app.command()
def gate(
    manifest_path: str = typer.Argument(..., help="Signed Composition Manifest (abom.json)."),
    tool: str = typer.Option(..., "--tool", "-t", help="Tool the agent wants to call."),
    endpoint: str = typer.Option(None, "--endpoint", "-e", help="Egress endpoint, if any."),
    classification: str = typer.Option(None, "--classification", "-c", help="Data classification touched."),
    trusted_key: list[str] = typer.Option(
        None, "--trusted-key", "-k",
        help="Pin an authorized signer key_id (repeatable). When set, the gate "
             "verifies the manifest signature before enforcing and refuses a "
             "manifest that is unsigned, tampered, or signed by an untrusted key.",
    ),
    log_file: str = typer.Option(
        None, "--log", "-l",
        help="Append the notarized decision to a durable transparency log file "
             "(survives restart). Feed this file to `abom witness` / `abom anchor`.",
    ),
    card: str = typer.Option(None, "--card", help="Also write an SVG decision card to this path."),
    verbose: int = typer.Option(0, "-v", "--verbose", count=True, help="-v info, -vv debug (to stderr)."),
    quiet: bool = typer.Option(False, "-q", "--quiet", help="Errors only."),
    json_logs: bool = typer.Option(False, "--json-logs", help="Structured NDJSON logs to stderr (CI)."),
):
    """Decide ALLOW/DENY for one agent action against the signed manifest.

    Deny-by-default: exits non-zero when the action is outside the signed
    envelope, so it gates a tool call inline (in CI or agent middleware). The
    decision is notarized in a Merkle transparency log with a signed tree head.

    Pass --trusted-key to pin the authorized signer: the gate then refuses to
    enforce against a manifest it cannot authenticate (unsigned / re-signed).
    Pass --log to persist the decision to a durable log you can then witness/anchor.
    """
    from .gate import Action, Gate

    log.setup(verbose, quiet, json_logs)
    manifest = json.loads(Path(manifest_path).read_text())
    pinned = set(trusted_key) if trusted_key else None
    try:
        g = Gate(manifest, run_id=manifest.get("agent", {}).get("name", "run"),
                 trusted_keys=pinned, log_path=log_file)
    except ValueError as exc:
        typer.secho(f"  ✗ REFUSED — {exc}", fg="red", bold=True, err=True)
        raise typer.Exit(2)
    decision = g.check(Action(tool=tool, endpoint=endpoint, data_classification=classification))

    if decision.allowed:
        typer.secho(f"  ALLOW  {tool}", fg="green", bold=True)
        typer.secho(f"  {decision.reason}", fg="bright_black")
    else:
        typer.secho(f"  DENY   {tool}  ({decision.rule})", fg="red", bold=True)
        typer.secho(f"  {decision.reason}", fg="bright_black")
    typer.secho(
        f"  notarized · log root {decision.tree_head['root_hash'][:24]}… "
        f"· signed key {decision.head_signature['key_id']}",
        fg="bright_black",
    )
    if card:
        Path(card).write_text(_viz.gate_card(decision.as_dict(), tool))
        typer.secho(f"  → wrote card {card}", fg="bright_black")
    raise typer.Exit(0 if decision.allowed else 1)


@app.command()
def broker(
    manifest_path: str = typer.Argument(..., help="Signed Composition Manifest (abom.json)."),
    upstream: str = typer.Option(..., "--upstream", "-u", help="Upstream MCP server URL to broker to."),
    trusted_key: list[str] = typer.Option(
        None, "--trusted-key", "-k",
        help="Pin an authorized signer key_id (repeatable). Refuses to broker "
             "against a manifest it cannot authenticate.",
    ),
    log_file: str = typer.Option(
        None, "--log", "-l",
        help="Append the notarized decision log to this file (durable, survives restart).",
    ),
    header: list[str] = typer.Option(None, "--header", help="Extra HTTP header for the upstream, 'Key: Value'."),
    verbose: int = typer.Option(0, "-v", "--verbose", count=True, help="-v info, -vv debug (to stderr)."),
    quiet: bool = typer.Option(False, "-q", "--quiet", help="Errors only."),
    json_logs: bool = typer.Option(False, "--json-logs", help="Structured NDJSON logs to stderr (CI)."),
):
    """Run the ABOM MCP broker: mediate an agent's tool calls against the manifest.

    The broker sits between the agent (MCP client, over stdio) and an upstream MCP
    server (over HTTP). Every ``tools/call`` is checked against the signed manifest
    BEFORE it is forwarded — deny-by-default, and each decision is notarized. A
    denied call is answered with a JSON-RPC error and never reaches the upstream.

    Enforcement is structural for brokered tools; it does not stop out-of-band
    egress (raw HTTP / subprocess) the agent makes without transiting the broker.
    """
    from .broker import McpBroker, connect_http
    from .gate import Gate

    log.setup(verbose, quiet, json_logs)
    manifest = json.loads(Path(manifest_path).read_text())
    pinned = set(trusted_key) if trusted_key else None

    headers: dict[str, str] = {}
    for h in (header or []):
        if ":" not in h:
            typer.secho(f"bad --header (need 'Key: Value'): {h}", fg="red", err=True)
            raise typer.Exit(2)
        k, v = h.split(":", 1)
        headers[k.strip()] = v.strip()

    try:
        gate = Gate(manifest, run_id=manifest.get("agent", {}).get("name", "run"),
                    trusted_keys=pinned, log_path=log_file)
    except ValueError as exc:
        typer.secho(f"  ✗ REFUSED — {exc}", fg="red", bold=True, err=True)
        raise typer.Exit(2)

    forward = connect_http(upstream, headers=headers or None)
    broker = McpBroker(gate=gate, forward=forward, learn_tools=True)

    typer.secho(f"  ABOM broker → {upstream}", fg="cyan", bold=True, err=True)
    typer.secho("  mediating tools/call against the signed manifest (deny-by-default)",
                fg="bright_black", err=True)

    # stdio JSON-RPC loop: MCP clients spawn a server and speak newline-delimited
    # JSON-RPC over stdin/stdout. Each line in is one request; each line out is one
    # response. EOF (client closed) ends the session.
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            request = json.loads(line)
        except json.JSONDecodeError:
            continue
        response = broker.handle(request)
        sys.stdout.write(json.dumps(response) + "\n")
        sys.stdout.flush()

    typer.secho(
        f"  session ended · {broker.stats.forwarded} forwarded · "
        f"{broker.stats.denied} denied",
        fg="bright_black", err=True,
    )


@app.command()
def witness(
    log_file: str = typer.Argument(..., help="Durable transparency log (.mlog) written by gate/broker --log."),
    witness_key: str = typer.Option(
        None, "--witness-key", "-w",
        help="Witness signing key PEM (its own, separate from the operator's). "
             "Default: <log>.witness.pem next to the log.",
    ),
    state_file: str = typer.Option(
        None, "--state",
        help="Where the witness remembers the last head it cosigned "
             "(default <log>.witness-state.json). This is what lets it detect a "
             "rewrite between runs.",
    ),
    out: str = typer.Option(None, "-o", "--out", help="Write the cosignature JSON here (default <log>.cosig.json)."),
    verbose: int = typer.Option(0, "-v", "--verbose", count=True, help="-v info, -vv debug (to stderr)."),
    quiet: bool = typer.Option(False, "-q", "--quiet", help="Errors only."),
    json_logs: bool = typer.Option(False, "--json-logs", help="Structured NDJSON logs to stderr (CI)."),
):
    """Cosign a transparency log's head with an independent witness key.

    The witness holds its own key and refuses to cosign unless the log only grew
    since the last time it saw it — so an operator who rewrites or truncates the
    log can't obtain the witness's signature. Run this on a *second party's*
    machine for genuine non-equivocation; self-hosted it is defense-in-depth.
    """
    from .merkle import PersistentMerkleLog
    from .signer import LocalSigner
    from .witness import Witness, WitnessRejected

    log.setup(verbose, quiet, json_logs)
    logp = Path(log_file)
    if not logp.exists():
        typer.secho(f"log not found: {log_file}", fg="red", err=True)
        raise typer.Exit(2)

    keyp = Path(witness_key) if witness_key else logp.with_suffix(logp.suffix + ".witness.pem")
    statep = Path(state_file) if state_file else logp.with_suffix(logp.suffix + ".witness-state.json")
    outp = Path(out) if out else logp.with_suffix(logp.suffix + ".cosig.json")

    w = Witness(LocalSigner(sign.load_or_create_key(keyp)))
    prev = json.loads(statep.read_text()) if statep.exists() else None
    if prev:
        w.restore(prev)

    mlog = PersistentMerkleLog(logp)
    head = mlog.head().as_dict()
    consistency = (mlog.prove_consistency(prev["last_size"])
                   if prev and head["tree_size"] > prev["last_size"] else None)

    try:
        cosig = w.cosign(head, consistency=consistency)
    except WitnessRejected as exc:
        typer.secho(f"  ✗ WITNESS REFUSED — {exc}", fg="red", bold=True, err=True)
        raise typer.Exit(1)

    outp.write_text(json.dumps(cosig, indent=2))
    statep.write_text(json.dumps(w.state()))
    typer.secho(f"  ✓ cosigned  size={cosig['tree_size']}  root {cosig['root_hash'][:16]}…",
                fg="green", bold=True)
    typer.secho(f"  witness key {w.key_id} · → {outp}", fg="bright_black")


@app.command()
def anchor(
    log_file: str = typer.Argument(..., help="Durable transparency log (.mlog) whose head to timestamp."),
    tsa_url: str = typer.Option(
        "https://freetsa.org/tsr", "--tsa",
        help="RFC 3161 Time-Stamping Authority URL (default freetsa.org; use an "
             "eIDAS qualified TSA for a legal presumption under eIDAS Art. 41).",
    ),
    out: str = typer.Option(None, "-o", "--out", help="Write the timestamp record here (default <log>.anchor.json)."),
    verbose: int = typer.Option(0, "-v", "--verbose", count=True, help="-v info, -vv debug (to stderr)."),
    quiet: bool = typer.Option(False, "-q", "--quiet", help="Errors only."),
    json_logs: bool = typer.Option(False, "--json-logs", help="Structured NDJSON logs to stderr (CI)."),
):
    """Anchor a transparency log's head to an RFC 3161 timestamp authority.

    Proves the log root existed, unaltered, by time T — to a third party the
    operator does not control. The signed token is stored for offline
    re-verification.
    """
    from .anchor import anchor_head, TimestampError
    from .merkle import PersistentMerkleLog

    log.setup(verbose, quiet, json_logs)
    logp = Path(log_file)
    if not logp.exists():
        typer.secho(f"log not found: {log_file}", fg="red", err=True)
        raise typer.Exit(2)

    head = PersistentMerkleLog(logp).head().as_dict()
    typer.secho(f"  timestamping root {head['root_hash'][:16]}… via {tsa_url}", fg="cyan", err=True)
    try:
        token = anchor_head(head, tsa_url=tsa_url)
    except TimestampError as exc:
        typer.secho(f"  ✗ {exc}", fg="red", bold=True, err=True)
        raise typer.Exit(1)

    outp = Path(out) if out else logp.with_suffix(logp.suffix + ".anchor.json")
    outp.write_text(json.dumps(token, indent=2))
    typer.secho(f"  ✓ anchored  size={head['tree_size']}  root {head['root_hash'][:16]}…",
                fg="green", bold=True)
    typer.secho(f"  RFC 3161 token from {tsa_url} · → {outp}", fg="bright_black")


@app.command("witness-verify")
def witness_verify(
    cosig_file: str = typer.Argument(..., help="A cosignature JSON written by `abom witness`."),
    expect_key: str = typer.Option(
        None, "--expect-key", "-k",
        help="Require this witness key_id (pin the authorized witness). "
             "Without it, verifies the signature against the key in the cosignature.",
    ),
    verbose: int = typer.Option(0, "-v", "--verbose", count=True, help="-v info, -vv debug (to stderr)."),
    quiet: bool = typer.Option(False, "-q", "--quiet", help="Errors only."),
    json_logs: bool = typer.Option(False, "--json-logs", help="Structured NDJSON logs to stderr (CI)."),
):
    """Verify a witness cosignature — the check an auditor runs.

    Confirms the attested (tree_size, root_hash) was signed by the witness key,
    with no trust in the operator. With --expect-key, also pins which witness is
    authorized. Exit 0 = valid, exit 1 = invalid.
    """
    from .witness import Witness

    log.setup(verbose, quiet, json_logs)
    try:
        cosig = json.loads(Path(cosig_file).read_text())
    except Exception as exc:
        typer.secho(f"could not read {cosig_file}: {exc}", fg="red", err=True)
        raise typer.Exit(2)

    pub = cosig.get("public_key")
    if not pub:
        typer.secho("cosignature has no public_key", fg="red", err=True)
        raise typer.Exit(2)

    ok = Witness.verify_cosignature(cosig, witness_public_key_b64=pub)
    if ok and expect_key and cosig.get("witness_key_id") != expect_key:
        ok = False
        reason = f" — signed by {cosig.get('witness_key_id')}, not the pinned {expect_key}"
    else:
        reason = ""

    if ok:
        typer.secho(
            f"\n  ✓ VALID — witness {cosig.get('witness_key_id')} attests "
            f"size={cosig.get('tree_size')}, root {cosig.get('root_hash', '')[:16]}…\n",
            fg="green", bold=True,
        )
        raise typer.Exit(0)
    typer.secho(f"\n  ✗ INVALID cosignature{reason}\n", fg="red", bold=True)
    raise typer.Exit(1)


@app.command()
def version():
    """Print the abom and ABOM-spec versions."""
    typer.echo(f"abom {__version__} · ABOM spec v{bom.ABOM_VERSION}")


if __name__ == "__main__":
    app()
