"""CertiSigma Census CLI — command-line interface.

Usage:
    census scan /path/to/files --source inventory-hr
    census compare /path/to/suspect --manifest ./manifest.json
    census status --manifest ./manifest.json
"""

from __future__ import annotations

import csv
import json
import logging
import os
import re
import sys
import time
from contextlib import nullcontext
from pathlib import Path

import click
from certisigma.exceptions import NotFoundError, ValidationError

from . import __version__

logger = logging.getLogger("certisigma_census")

_SIZE_UNITS: dict[str, int] = {
    "B": 1,
    "K": 1024,
    "KB": 1024,
    "M": 1024 ** 2,
    "MB": 1024 ** 2,
    "G": 1024 ** 3,
    "GB": 1024 ** 3,
    "T": 1024 ** 4,
    "TB": 1024 ** 4,
}
_SIZE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([A-Za-z]*)\s*$")


def _parse_size(value: str) -> int:
    """Parse a human-readable size string into bytes.

    Accepts: ``100``, ``512K``, ``10MB``, ``1.5G``.
    Raises ``click.BadParameter`` on invalid input.
    """
    m = _SIZE_RE.match(value)
    if not m:
        raise click.BadParameter(f"Invalid size: {value!r}. Use e.g. 100, 512K, 10M, 1G.")
    number, unit = float(m.group(1)), m.group(2).upper()
    if not unit:
        return int(number)
    if unit not in _SIZE_UNITS:
        raise click.BadParameter(
            f"Unknown size unit: {unit!r}. Valid: {', '.join(sorted(_SIZE_UNITS))}."
        )
    return int(number * _SIZE_UNITS[unit])


MAX_LABEL_LEN = 256


def _validate_label(value: str | None, param: str = "--source") -> str | None:
    """Reject CLI string parameters that exceed ``MAX_LABEL_LEN``.

    Prevents audit-log bloat and API payload inflation from
    arbitrarily long free-text inputs.
    """
    if value is None:
        return None
    if len(value) > MAX_LABEL_LEN:
        raise click.BadParameter(
            f"Value too long ({len(value)} chars, max {MAX_LABEL_LEN}).",
            param_hint=param,
        )
    return value


class _JsonLogFormatter(logging.Formatter):
    """Emit one JSON object per log line — ready for ELK/Splunk/SIEM ingest."""

    def format(self, record: logging.LogRecord) -> str:
        entry = {
            "timestamp": self.formatTime(record, datefmt="%Y-%m-%dT%H:%M:%S"),
            "level": record.levelname,
            "module": record.module,
            "message": record.getMessage(),
        }
        if record.exc_info and record.exc_info[1]:
            entry["exception"] = self.formatException(record.exc_info)
        return json.dumps(entry, ensure_ascii=False, default=str)


def _setup_logging(verbose: bool, log_format: str = "text") -> None:
    level = logging.DEBUG if verbose else logging.INFO
    handler = logging.StreamHandler(sys.stderr)
    handler.setLevel(level)

    if log_format == "json":
        handler.setFormatter(_JsonLogFormatter())
    else:
        handler.setFormatter(logging.Formatter(
            "%(asctime)s %(levelname)-8s %(message)s", datefmt="%H:%M:%S",
        ))

    root_logger = logging.getLogger("certisigma_census")
    root_logger.setLevel(level)
    root_logger.addHandler(handler)


_cached_config: dict | None = None


def _get_config() -> dict:
    global _cached_config
    if _cached_config is None:
        from .config import load_config
        _cached_config = load_config()
    return _cached_config


def _reset_config_cache() -> None:
    """Reset config cache — for testing only."""
    global _cached_config
    _cached_config = None


def _get_client(api_key: str | None = None, base_url: str | None = None):
    """Create a CertiSigmaClient and register close() on the active Click context."""
    from certisigma import CertiSigmaClient

    cfg = _get_config()
    key = api_key or os.environ.get("CERTISIGMA_API_KEY") or cfg.get("api_key") or None
    url = base_url or os.environ.get("CERTISIGMA_BASE_URL") or cfg.get("base_url") or None
    kwargs: dict = {}
    if key:
        kwargs["api_key"] = key
    if url:
        kwargs["base_url"] = url
    client = CertiSigmaClient(**kwargs)
    ctx = click.get_current_context(silent=True)
    if ctx is not None:
        ctx.call_on_close(client.close)
    return client


def _progress(items: list, *, label: str, enabled: bool):
    """Wrap *items* with ``click.progressbar`` when *enabled*, else pass through."""
    if enabled and items:
        return click.progressbar(items, label=label, show_pos=True)
    return nullcontext(items)


BATCH_SIZE = 100


def _with_meta(data: dict, elapsed: float | None = None) -> dict:
    """Enrich JSON output with tool metadata for forensic traceability.

    Returns a **new** dict — the original *data* is never mutated.
    """
    enriched = {**data, "census_version": __version__}
    if elapsed is not None:
        enriched["elapsed_seconds"] = round(elapsed, 2)
    return enriched


def _emit_jsonl(
    items: list[dict],
    elapsed: float | None = None,
    *,
    extra_summary: dict | None = None,
) -> None:
    """Emit a sequence of dicts as JSON Lines (one JSON object per line).

    Each line is flushed immediately for streaming to log pipelines.
    A final ``_summary`` line is emitted with census_version and elapsed_seconds.
    *extra_summary* fields are merged into the trailer (e.g. differential metadata).
    """
    for item in items:
        click.echo(json.dumps(item, default=str))
    summary: dict = {"_summary": True, "count": len(items), "census_version": __version__}
    if elapsed is not None:
        summary["elapsed_seconds"] = round(elapsed, 2)
    if extra_summary:
        _RESERVED = frozenset(("_summary", "count", "census_version", "elapsed_seconds"))
        summary.update({k: v for k, v in extra_summary.items() if k not in _RESERVED})
    click.echo(json.dumps(summary, default=str))


def _run_on_match(command: str, data: dict) -> None:
    """Execute *command* with JSON *data* on stdin.

    Best-effort: logs a warning on failure but never raises.
    Child stdout is suppressed to prevent contamination of Census output
    (especially JSONL streams). The command can still write to stderr.
    """
    import subprocess

    try:
        proc = subprocess.run(
            command,
            input=json.dumps(data, default=str),
            shell=True,
            text=True,
            timeout=30,
            check=False,
            stdout=subprocess.DEVNULL,
        )
        if proc.returncode != 0:
            logger.warning("--on-match command exited with code %d", proc.returncode)
    except Exception as exc:
        logger.warning("--on-match command failed: %s", exc)


def _audit(action: str, params: dict | None = None, result: dict | None = None) -> None:
    """Best-effort audit log entry — never raises."""
    try:
        from .audit_log import append_entry
        append_entry(action, params=params, result=result)
    except Exception:
        logger.debug("Audit log write failed", exc_info=True)


def _resolve_manifest_path(user_path: Path) -> Path:
    """Resolve a user-provided manifest path to the actual file on disk.

    Checks: exact path, .db sibling (if .json given), .json sibling (if .db given).
    Returns the path that exists, or the original path if nothing found.
    """
    if user_path.exists():
        return user_path
    from .manifest import Manifest
    db_path = Manifest._resolve_db_path(user_path)
    if db_path != user_path and db_path.exists():
        return db_path
    if user_path.suffix == ".db" and user_path.with_suffix(".json").exists():
        return user_path.with_suffix(".json")
    return user_path


def _load_manifest(path: Path) -> "Manifest":
    """Load a manifest with user-friendly error on corrupt/unreadable DB."""
    from .manifest import Manifest

    try:
        return Manifest.load(path)
    except Exception as exc:
        raise click.ClickException(f"Cannot load manifest {path}: {exc}") from exc


def _echo_info(msg: str, **kwargs) -> None:
    """Emit informational message — suppressed by ``--quiet``.

    Error messages (``err=True``) are never suppressed.
    """
    if kwargs.get("err"):
        click.echo(msg, **kwargs)
        return
    ctx = click.get_current_context(silent=True)
    if ctx and ctx.obj and ctx.obj.get("quiet"):
        return
    click.echo(msg, **kwargs)


@click.group()
@click.version_option(__version__, prog_name="certisigma-census")
@click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.")
@click.option("-q", "--quiet", is_flag=True, help="Suppress informational output (errors and --json still shown).")
@click.option("--log-format", "log_format", type=click.Choice(["text", "json"]), default="text", help="Log output format (default: text).")
@click.option("--no-color", is_flag=True, envvar="NO_COLOR", help="Disable colored output (also respects NO_COLOR env, see no-color.org).")
@click.pass_context
def main(ctx: click.Context, verbose: bool, quiet: bool, log_format: str, no_color: bool) -> None:
    """CertiSigma Census — Cryptographic file inventory & breach detection."""
    _setup_logging(verbose, log_format=log_format)
    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose
    ctx.obj["quiet"] = quiet
    if no_color or os.environ.get("NO_COLOR") is not None:
        ctx.color = False


@main.command()
@click.argument("directory", type=click.Path(exists=True, file_okay=False, resolve_path=True))
@click.option("--source", default=None, help="Source label for attestations (e.g. 'inventory-hr').")
@click.option("--manifest", "manifest_path", default=None, type=click.Path(), help="Path to save the manifest. Default: <directory>/.census-manifest.db")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
@click.option("--dry-run", is_flag=True, help="Scan and hash only, do not attest.")
@click.option("--resume", is_flag=True, help="Resume interrupted scan: skip unchanged files already in manifest.")
@click.option("--include", "include_globs", multiple=True, help="Only include files matching this glob (repeatable, e.g. --include '*.pdf').")
@click.option("--exclude", "exclude_globs", multiple=True, help="Exclude files matching this glob (repeatable, e.g. --exclude '*.log').")
@click.option("--min-size", "min_size_str", default=None, help="Skip files smaller than this (e.g. 1K, 10M).")
@click.option("--max-size", "max_size_str", default=None, help="Skip files larger than this (e.g. 100M, 1G). Default: 5G.")
@click.option("--workers", default=1, type=int, help="Parallel hashing workers (1 = sequential). Default: 1.")
@click.option("--attest-manifest", is_flag=True, help="Attest the manifest's own SHA-256 after scan (proves manifest existed at scan time).")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON summary.")
@click.pass_context
def scan(
    ctx: click.Context,
    directory: str,
    source: str | None,
    manifest_path: str | None,
    api_key: str | None,
    base_url: str | None,
    dry_run: bool,
    resume: bool,
    include_globs: tuple[str, ...],
    exclude_globs: tuple[str, ...],
    min_size_str: str | None,
    max_size_str: str | None,
    workers: int,
    attest_manifest: bool,
    json_output: bool,
) -> None:
    """Scan a directory, compute hashes, and attest them via CertiSigma."""
    from .scanner import DEFAULT_MAX_FILE_SIZE, MAX_WORKERS, scan_directory

    verbose = ctx.obj.get("verbose", False)
    quiet = ctx.obj.get("quiet", False)
    show_progress = not verbose and not json_output and not quiet

    if workers < 1:
        raise click.BadParameter("Workers must be >= 1.", param_hint="--workers")
    if workers > MAX_WORKERS:
        click.echo(f"Capping workers to {MAX_WORKERS}.", err=True)
        workers = MAX_WORKERS

    source = _validate_label(source, "--source")
    min_size = _parse_size(min_size_str) if min_size_str else 0
    max_size = _parse_size(max_size_str) if max_size_str else DEFAULT_MAX_FILE_SIZE

    t0 = time.monotonic()
    root = Path(directory)
    mpath = Path(manifest_path) if manifest_path else root / ".census-manifest.db"

    existing_manifest = None
    if resume:
        resolved = _resolve_manifest_path(mpath)
        if resolved.exists():
            existing_manifest = _load_manifest(resolved)
            if not json_output:
                _echo_info(f"Resuming from {resolved} ({existing_manifest.total} cached entries)")

    try:
        if not json_output:
            _echo_info(f"Scanning {root} ...")
        manifest = scan_directory(
            root,
            show_progress=show_progress,
            include_globs=include_globs,
            exclude_globs=exclude_globs,
            min_file_size=min_size,
            max_file_size=max_size,
            existing_manifest=existing_manifest,
            workers=workers,
        )

        if existing_manifest:
            try:
                existing_manifest.merge(manifest)
            finally:
                manifest.close()
            manifest = existing_manifest

        if not json_output:
            _echo_info(f"Found {manifest.total} files.")

        if dry_run:
            try:
                manifest.save(mpath)
            except Exception as exc:
                click.echo(click.style(f"Cannot save manifest: {exc}", fg="red"), err=True)
                sys.exit(1)
            _audit("scan", {"directory": str(root), "dry_run": True}, {"total": manifest.total})
            if json_output:
                click.echo(json.dumps(_with_meta({
                    "directory": str(root), "manifest": str(mpath),
                    "total": manifest.total, "attested": 0, "dry_run": True,
                }, time.monotonic() - t0), indent=2, default=str))
            else:
                _echo_info(f"Dry run — manifest saved to {mpath}")
            return

        client = _get_client(api_key, base_url)
        attested = 0

        from .retry import retry_api_call

        hashes_to_attest = manifest.unattested_hashes()
        batches = [hashes_to_attest[i : i + BATCH_SIZE] for i in range(0, len(hashes_to_attest), BATCH_SIZE)]
        with _progress(batches, label="Attesting", enabled=show_progress) as bar:
            for batch in bar:
                try:
                    result = retry_api_call(client.batch_attest, batch, source=source)
                    for att in result.attestations:
                        att_id = att.get("attestation_id") or att.get("id")
                        h = att.get("hash_hex", "")
                        ts = att.get("timestamp", att.get("registered_at", ""))
                        if att_id and h:
                            manifest.mark_attested(h, str(att_id), ts)
                            attested += 1
                except Exception as exc:
                    logger.error("Batch attest failed: %s", exc)
                try:
                    manifest.save(mpath)
                except Exception as exc:
                    logger.error("Cannot save manifest (batch persist): %s", exc)

        manifest_attestation_id = None
        if attest_manifest:
            try:
                from certisigma import hash_file as sdk_hash_file
                manifest_hash = sdk_hash_file(str(mpath))
                att = retry_api_call(client.attest, manifest_hash, source="census-manifest")
                manifest_attestation_id = att.id
                _audit("attest_manifest", {"manifest": str(mpath), "hash": manifest_hash},
                       {"attestation_id": manifest_attestation_id})
                if not json_output:
                    _echo_info(f"Manifest attested: {manifest_attestation_id}")
            except Exception as exc:
                logger.error("Manifest attestation failed: %s", exc)

        _audit("scan", {"directory": str(root), "dry_run": False}, {"total": manifest.total, "attested": attested})

        scan_data: dict = {
            "directory": str(root), "manifest": str(mpath),
            "total": manifest.total, "attested": attested, "dry_run": False,
        }
        if manifest_attestation_id:
            scan_data["manifest_attestation_id"] = manifest_attestation_id
        scan_result = _with_meta(scan_data, time.monotonic() - t0)

        attest_failed = (
            not dry_run
            and manifest.total > 0
            and attested == 0
            and len(hashes_to_attest) > 0
        )

        elapsed = time.monotonic() - t0
        if json_output:
            click.echo(json.dumps(scan_result, indent=2, default=str))
        else:
            _echo_info(f"Attested {attested}/{manifest.total} files. Manifest: {mpath}")
            _echo_info(f"Completed in {elapsed:.1f}s")
        if attest_failed:
            click.echo(click.style(
                "Attestation failed: 0 hashes attested out of "
                f"{len(hashes_to_attest)} pending", fg="red",
            ), err=True)
            sys.exit(1)
    finally:
        if existing_manifest is not None:
            existing_manifest.close()
        elif 'manifest' in locals():
            manifest.close()


@main.command()
@click.argument("suspect_dir", type=click.Path(exists=True, file_okay=False, resolve_path=True))
@click.option("--manifest", "manifest_path", default=None, type=click.Path(), help="Local manifest for cross-referencing original paths.")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
@click.option("--output", "output_path", default=None, type=click.Path(), help="Save report as JSON or CSV (detected by extension).")
@click.option("--include", "include_globs", multiple=True, help="Only include files matching this glob (repeatable).")
@click.option("--exclude", "exclude_globs", multiple=True, help="Exclude files matching this glob (repeatable).")
@click.option("--min-size", "min_size_str", default=None, help="Skip files smaller than this (e.g. 1K, 10M).")
@click.option("--max-size", "max_size_str", default=None, help="Skip files larger than this (e.g. 100M, 1G).")
@click.option("--detailed", is_flag=True, help="Enriched results: source label, T0/T1/T2 level, tier timestamps (requires API key).")
@click.option("--workers", default=1, type=int, help="Parallel hashing workers (1 = sequential). Default: 1.")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@click.option("--format", "output_format", type=click.Choice(["text", "json", "sarif", "jsonl"]), default="text", help="Output format.")
@click.option("--exit-zero", is_flag=True, help="Always exit 0 (report-only mode for CI pipelines).")
@click.option("--summary", is_flag=True, help="Show only summary counts, no match details.")
@click.option("--on-match", "on_match_cmd", default=None, help="Run command with match results as JSON on stdin (only if matches > 0).")
@click.pass_context
def compare(
    ctx: click.Context,
    suspect_dir: str,
    manifest_path: str | None,
    api_key: str | None,
    base_url: str | None,
    output_path: str | None,
    include_globs: tuple[str, ...],
    exclude_globs: tuple[str, ...],
    min_size_str: str | None,
    max_size_str: str | None,
    detailed: bool,
    workers: int,
    json_output: bool,
    output_format: str,
    exit_zero: bool,
    summary: bool,
    on_match_cmd: str | None,
) -> None:
    """Compare suspect files against the CertiSigma registry."""
    from .compare import compare_directory
    from .scanner import DEFAULT_MAX_FILE_SIZE, MAX_WORKERS

    t0 = time.monotonic()

    if output_format != "text":
        json_output = output_format in ("json", "sarif", "jsonl")
    elif json_output:
        output_format = "json"

    verbose = ctx.obj.get("verbose", False)
    quiet = ctx.obj.get("quiet", False)
    show_progress = not verbose and not json_output and not quiet

    if workers < 1:
        raise click.BadParameter("Workers must be >= 1.", param_hint="--workers")
    if workers > MAX_WORKERS:
        click.echo(f"Capping workers to {MAX_WORKERS}.", err=True)
        workers = MAX_WORKERS

    min_size = _parse_size(min_size_str) if min_size_str else 0
    max_size = _parse_size(max_size_str) if max_size_str else DEFAULT_MAX_FILE_SIZE

    local_manifest = None
    if manifest_path:
        resolved = _resolve_manifest_path(Path(manifest_path))
        if not resolved.exists():
            raise click.BadParameter(f"Manifest not found: {manifest_path}")
        local_manifest = _load_manifest(resolved)

    try:
        client = _get_client(api_key, base_url)
        report = compare_directory(
            Path(suspect_dir), client, local_manifest=local_manifest,
            show_progress=show_progress,
            include_globs=include_globs,
            exclude_globs=exclude_globs,
            min_file_size=min_size,
            max_file_size=max_size,
            detailed=detailed,
            workers=workers,
        )

        _audit("compare", {"suspect_dir": suspect_dir}, {
            "total": report.total_suspect, "matched": report.matched,
        })

        if output_format == "sarif":
            from .sarif import compare_report_to_sarif
            sarif_data = compare_report_to_sarif(report, __version__, suspect_dir)
            sarif_json = json.dumps(sarif_data, indent=2, default=str)
            if output_path:
                try:
                    Path(output_path).write_text(sarif_json)
                    click.echo(f"SARIF report saved to {output_path}", err=True)
                except OSError as exc:
                    click.echo(click.style(f"Cannot write SARIF: {exc}", fg="red"), err=True)
                    sys.exit(1)
            else:
                click.echo(sarif_json)
        elif output_format == "jsonl":
            if output_path:
                click.echo("Note: --output is ignored when --format jsonl is set.", err=True)
            from dataclasses import asdict
            items = [asdict(m) for m in report.matches]
            _emit_jsonl(items, time.monotonic() - t0, extra_summary={
                "total_suspect": report.total_suspect,
                "matched": report.matched,
                "not_matched": report.not_matched,
                "errors": report.errors,
            })
        elif json_output:
            if output_path:
                click.echo(
                    "Note: --output is ignored when --json is set.",
                    err=True,
                )
            from dataclasses import asdict
            data = _with_meta(asdict(report), time.monotonic() - t0)
            click.echo(json.dumps(data, indent=2, default=str))
        else:
            elapsed = time.monotonic() - t0
            _echo_info(f"\nSuspect files:  {report.total_suspect}")
            _echo_info(f"Matched:        {report.matched}")
            _echo_info(f"Not matched:    {report.not_matched}")
            _echo_info(f"Errors:         {report.errors}")

            if not summary and report.matches:
                _echo_info("\n--- Matches ---")
                for m in report.matches:
                    inv = f" (inventory: {m.inventory_path})" if m.inventory_path else ""
                    att_id = m.attestation_id or "unknown"
                    level = m.level or "?"
                    src = f" src={m.source}" if m.source else ""
                    _echo_info(f"  {m.suspect_path} → {att_id} [{level}]{src}{inv}")

            _echo_info(f"Completed in {elapsed:.1f}s")

            if output_path:
                try:
                    _write_compare_report(report, Path(output_path))
                except (OSError, TypeError, ValueError) as exc:
                    click.echo(click.style(f"Cannot write report: {exc}", fg="red"), err=True)
                    sys.exit(1)
                _echo_info(f"\nReport saved to {output_path}")

        if on_match_cmd and report.matched > 0:
            from dataclasses import asdict
            _run_on_match(on_match_cmd, _with_meta(
                {"matched": report.matched, "matches": [asdict(m) for m in report.matches]},
                time.monotonic() - t0,
            ))

        if exit_zero:
            sys.exit(0)
        sys.exit(0 if report.matched == 0 else 1)
    finally:
        if local_manifest is not None:
            local_manifest.close()


def _write_compare_report(report, path: Path) -> None:
    """Write a CompareReport as JSON or CSV (detected by file extension)."""
    from dataclasses import asdict

    if path.suffix.lower() == ".csv":
        fieldnames = [
            "suspect_path", "hash_hex", "matched", "attestation_id",
            "level", "source", "timestamp", "inventory_path",
        ]
        with path.open("w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames)
            writer.writeheader()
            for m in report.matches:
                writer.writerow({
                    "suspect_path": m.suspect_path,
                    "hash_hex": m.hash_hex,
                    "matched": "true",
                    "attestation_id": m.attestation_id or "",
                    "level": m.level or "",
                    "source": m.source or "",
                    "timestamp": m.timestamp or "",
                    "inventory_path": m.inventory_path or "",
                })
    else:
        data = asdict(report)
        path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")


@main.command()
@click.argument("manifest_path", type=click.Path())
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
def status(manifest_path: str, json_output: bool) -> None:
    """Show manifest status (total files, attested, pending)."""
    t0 = time.monotonic()
    resolved = _resolve_manifest_path(Path(manifest_path))
    if not resolved.exists():
        raise click.BadParameter(f"Manifest not found: {manifest_path}")
    with _load_manifest(resolved) as manifest:
        data = {
            "manifest": str(resolved),
            "root": manifest.root_dir,
            "total": manifest.total,
            "attested": manifest.attested_count,
            "pending": manifest.total - manifest.attested_count,
        }

    if json_output:
        click.echo(json.dumps(_with_meta(data, time.monotonic() - t0), indent=2, default=str))
    else:
        _echo_info(f"Manifest: {data['manifest']}")
        _echo_info(f"Root:     {data['root']}")
        _echo_info(f"Total:    {data['total']}")
        _echo_info(f"Attested: {data['attested']}")
        _echo_info(f"Pending:  {data['pending']}")


@main.command(name="export")
@click.argument("manifest_path", type=click.Path())
@click.option("--format", "fmt", type=click.Choice(["csv", "json", "sha256sum"]), default="csv", help="Output format (default: csv).")
@click.option("--output", "output_path", default=None, type=click.Path(), help="Output file. Default: stdout.")
def export_manifest(manifest_path: str, fmt: str, output_path: str | None) -> None:
    """Export a manifest as CSV, JSON, or sha256sum (GNU coreutils compatible)."""
    resolved = _resolve_manifest_path(Path(manifest_path))
    if not resolved.exists():
        raise click.BadParameter(f"Manifest not found: {manifest_path}")
    with _load_manifest(resolved) as manifest:
        if fmt == "csv":
            _export_manifest_csv(manifest, output_path)
        elif fmt == "sha256sum":
            _export_manifest_sha256sum(manifest, output_path)
        else:
            _export_manifest_json(manifest, output_path)


def _export_manifest_csv(manifest, output_path: str | None) -> None:
    from dataclasses import asdict

    fieldnames = [
        "hash_hex", "path", "size", "mtime",
        "attested", "attestation_id", "attested_at",
    ]
    if output_path:
        f = open(output_path, "w", newline="", encoding="utf-8")
    else:
        f = sys.stdout

    try:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        for entry in manifest.iter_entries():
            writer.writerow(asdict(entry))
    finally:
        if output_path:
            f.close()


def _export_manifest_sha256sum(manifest, output_path: str | None) -> None:
    """Export in GNU coreutils sha256sum format: ``<hash>  <path>``."""
    if output_path:
        f = open(output_path, "w", encoding="utf-8")
    else:
        f = sys.stdout

    try:
        for entry in manifest.iter_entries():
            f.write(f"{entry.hash_hex}  {entry.path}\n")
    finally:
        if output_path:
            f.close()


def _export_manifest_json(manifest, output_path: str | None) -> None:
    from dataclasses import asdict

    data = {
        "version": manifest.version,
        "root_dir": manifest.root_dir,
        "total": manifest.total,
        "attested": manifest.attested_count,
        "entries": [asdict(e) for e in manifest.iter_entries()],
    }
    text = json.dumps(data, indent=2, ensure_ascii=False, default=str)
    if output_path:
        Path(output_path).write_text(text, encoding="utf-8")
    else:
        click.echo(text)


@main.command(name="verify")
@click.argument("hash_or_file")
@click.option("--file", "is_file", is_flag=True, help="Treat argument as a file path instead of a hash.")
@click.option("--save-ots", "ots_path", default=None, type=click.Path(), help="Save OTS proof to this path.")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
def verify_cmd(
    hash_or_file: str,
    is_file: bool,
    ots_path: str | None,
    json_output: bool,
    api_key: str | None,
    base_url: str | None,
) -> None:
    """Verify a hash (or file) against the CertiSigma registry.

    Displays the full evidence chain: T0 (ECDSA), T1 (TSA), T2 (Bitcoin).
    No API key required — all verification endpoints are public.

    \b
    Examples:
        census verify a1b2c3...         # verify a known hash
        census verify doc.pdf --file    # hash the file first
        census verify a1b2... --save-ots proof.ots
        census verify a1b2... --json
    """
    from certisigma import hash_file as sdk_hash_file

    from .evidence import verify_hash

    t0 = time.monotonic()

    if is_file:
        fpath = Path(hash_or_file)
        if not fpath.exists():
            raise click.BadParameter(f"File not found: {hash_or_file}")
        try:
            hash_hex = sdk_hash_file(str(fpath))
        except Exception as exc:
            raise click.ClickException(f"Cannot hash file: {exc}") from exc
        if not json_output:
            _echo_info(f"File:        {fpath}")
            _echo_info(f"SHA-256:     {hash_hex}")
    else:
        hash_hex = hash_or_file.lower().strip()
        if not re.fullmatch(r"[0-9a-f]{64}", hash_hex):
            raise click.BadParameter(
                f"Invalid SHA-256 hash: expected 64 hex characters, got {len(hash_hex)}."
            )

    client = _get_client(api_key, base_url)
    try:
        detail = verify_hash(client, hash_hex)
    except Exception as exc:
        if isinstance(exc, NotFoundError):
            msg = f"Attestation not found for hash {hash_hex[:16]}..."
        elif isinstance(exc, ValidationError):
            msg = f"Invalid request: {exc}"
        else:
            msg = str(exc)
        if json_output:
            click.echo(json.dumps({"error": msg, "hash_hex": hash_hex}, indent=2, default=str))
        else:
            click.echo(click.style(f"Error: {msg}", fg="red"), err=True)
        sys.exit(1)

    if json_output:
        from dataclasses import asdict
        click.echo(json.dumps(_with_meta(asdict(detail), time.monotonic() - t0), indent=2, default=str))
        return

    _echo_info(f"Hash:        {detail.hash_hex}")
    _echo_info(f"Status:      {'Attested' if detail.exists else 'Not found'}")

    if not detail.exists:
        return

    _echo_info(f"Level:       {detail.level or 'unknown'}")
    _echo_info(f"Registered:  {detail.timestamp or 'unknown'}")
    if detail.source:
        _echo_info(f"Source:      {detail.source}")
    _echo_info(f"ID:          {detail.attestation_id or 'unknown'}")

    if detail.t0 or detail.t1 or detail.t2:
        _echo_info("\nEvidence chain:")
        if detail.t0:
            algo = str(detail.t0.get("algorithm", "ECDSA-P256-SHA256"))
            sig = str(detail.t0.get("signature", ""))[:20]
            _echo_info(f"  T0  {algo}  sig: {sig}...")
        if detail.t1:
            tsa = detail.t1.get("tsaProvider", "TSA")
            ts = detail.t1.get("timestamp", "")
            _echo_info(f"  T1  {tsa}  {ts}")
        if detail.t2:
            block = detail.t2.get("bitcoinBlock", "")
            _echo_info(f"  T2  Bitcoin block {block}")
            if detail.blockchain_url:
                _echo_info(f"      Block:  {detail.blockchain_url}")
            if detail.blockchain_tx_url:
                _echo_info(f"      Tx:     {detail.blockchain_tx_url}")

    if ots_path and detail.exists and detail.attestation_id:
        try:
            from certisigma import save_ots_proof
            ev = client.get_evidence(detail.attestation_id)
            saved = save_ots_proof(ev, ots_path)
            if saved:
                _echo_info(f"\nOTS proof saved to {ots_path}")
            else:
                _echo_info("\nOTS proof not yet available (T2 pending)")
        except Exception as exc:
            logger.warning("Could not save OTS proof: %s", exc)
            click.echo(f"\nCould not save OTS proof: {exc}", err=True)


@main.command(name="integrity")
@click.argument("manifest_path", type=click.Path())
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@click.option("--format", "output_format", type=click.Choice(["text", "json", "jsonl"]), default="text", help="Output format.")
@click.option("--output", "output_path", default=None, type=click.Path(), help="Save results to file (CSV or JSON by extension).")
@click.option("--strict", is_flag=True, help="Exit with code 1 on any discrepancy.")
@click.option("--since", "since_path", default=None, type=click.Path(), help="Load previous state file — only report NEW findings.")
@click.option("--write-state", "write_state_path", default=None, type=click.Path(), help="Save current state for next differential run (default: auto-sidecar).")
@click.pass_context
def integrity_cmd(
    ctx: click.Context,
    manifest_path: str,
    json_output: bool,
    output_format: str,
    output_path: str | None,
    strict: bool,
    since_path: str | None,
    write_state_path: str | None,
) -> None:
    """Check file integrity against a manifest baseline.

    Re-hashes all files in the manifest's root directory and compares
    against stored SHA-256 baselines.  Detects modified, missing, and
    new files.

    This is a 100% local operation — no API calls, no API key needed.

    \b
    Differential mode (suppress known findings):
        --since STATE        Only report findings NOT in the previous state.
        --write-state STATE  Save current state for the next run.

    When --write-state is omitted but --since is given, state is saved to
    the auto-sidecar path: manifest.db.integrity-state.

    \b
    Examples:
        census integrity manifest.db
        census integrity manifest.db --strict
        census integrity manifest.db --json
        census integrity manifest.db --format jsonl
        census integrity manifest.db --output results.csv
        census integrity manifest.db --since state.json --write-state state.json
        census integrity manifest.db --since auto --write-state auto
    """
    from .evidence import integrity_check

    t0 = time.monotonic()

    if output_format != "text":
        json_output = output_format in ("json", "jsonl")
    elif json_output:
        output_format = "json"

    verbose = ctx.obj.get("verbose", False)
    quiet = ctx.obj.get("quiet", False)
    show_progress = not verbose and not json_output and not quiet

    resolved = _resolve_manifest_path(Path(manifest_path))
    if not resolved.exists():
        raise click.BadParameter(f"Manifest not found: {manifest_path}")

    with _load_manifest(resolved) as manifest:
        report = integrity_check(manifest, show_progress=show_progress)

    full_report = report
    diff_meta = None

    # ── Differential mode ────────────────────────────────────────────
    from .differential import (
        build_state,
        default_state_path,
        filter_report,
        load_state,
        save_state,
    )

    auto_state = default_state_path(resolved.resolve())
    resolved_since = Path(since_path) if since_path and since_path != "auto" else (auto_state if since_path == "auto" else None)
    resolved_write = Path(write_state_path) if write_state_path and write_state_path != "auto" else (auto_state if write_state_path == "auto" else None)

    if since_path and resolved_write is None:
        resolved_write = auto_state

    prev_state = None
    if resolved_since and resolved_since.exists():
        try:
            prev_state = load_state(resolved_since)
        except (ValueError, OSError, json.JSONDecodeError) as exc:
            if json_output:
                click.echo(json.dumps({"error": f"Cannot load state: {exc}"}, indent=2, default=str))
            else:
                click.echo(click.style(f"Cannot load state: {exc}", fg="red"), err=True)
            sys.exit(1)

    if prev_state is not None:
        report, diff_meta = filter_report(full_report, prev_state)

    current_state = build_state(full_report, previous=prev_state)

    if resolved_write:
        try:
            save_state(resolved_write, current_state)
        except (OSError, ValueError, TypeError) as exc:
            if json_output:
                click.echo(json.dumps({"error": f"Cannot save state: {exc}"}, indent=2, default=str))
            else:
                click.echo(click.style(f"Cannot save state: {exc}", fg="red"), err=True)
            sys.exit(1)

    # ── Audit (compliance: log both full and filtered counts) ────────
    audit_result = {
        "total_in_manifest": full_report.total_in_manifest,
        "unchanged": full_report.unchanged,
        "modified": len(full_report.modified),
        "missing": len(full_report.missing),
        "new_files": len(full_report.new_files),
        "errors": len(full_report.errors),
        "strict": strict,
    }
    if diff_meta:
        audit_result["differential"] = True
        audit_result["suppressed"] = diff_meta.suppressed
        audit_result["new_findings"] = diff_meta.new_findings
    _audit("integrity", {"manifest": str(resolved)}, audit_result)

    # ── Output (uses filtered report if differential) ────────────────
    def _diff_enrichment(d: dict) -> dict:
        """Add differential fields to JSON/JSONL output (additive, non-breaking).

        Returns a **new** dict — never mutates the input.
        """
        if not diff_meta:
            return d
        enriched = {
            **d,
            "differential": True,
            "suppressed": diff_meta.suppressed,
            "new_findings": diff_meta.new_findings,
        }
        if resolved_write:
            enriched["state_path"] = str(resolved_write)
        return enriched

    if output_format == "jsonl":
        if output_path:
            click.echo("Note: --output is ignored when --format jsonl is set.", err=True)
        from dataclasses import asdict
        items: list[dict] = []
        for m in report.modified:
            items.append({"status": "modified", **asdict(m)})
        for p in report.missing:
            items.append({"status": "missing", "path": p})
        for p in report.new_files:
            items.append({"status": "new", "path": p})
        integrity_summary = {
            "total_in_manifest": full_report.total_in_manifest,
            "unchanged": full_report.unchanged,
            "modified": len(report.modified),
            "missing": len(report.missing),
            "new_files": len(report.new_files),
        }
        _emit_jsonl(items, time.monotonic() - t0, extra_summary=_diff_enrichment(integrity_summary))
    elif json_output:
        if output_path:
            try:
                _write_integrity_report(report, Path(output_path))
            except (OSError, TypeError, ValueError) as exc:
                click.echo(json.dumps({"error": f"Cannot write report: {exc}"}, indent=2, default=str))
                sys.exit(1)
        from dataclasses import asdict
        result_data = _diff_enrichment(_with_meta(asdict(report), time.monotonic() - t0))
        click.echo(json.dumps(result_data, indent=2, default=str))
    else:
        _echo_info(f"Integrity check: {report.root_dir} ({full_report.total_in_manifest} files in manifest)")
        if diff_meta:
            _echo_info(f"  (differential: {diff_meta.new_findings} new, {diff_meta.suppressed} suppressed)")
        _echo_info("")
        _echo_info(f"  Unchanged:  {report.unchanged}")
        _echo_info(f"  Modified:   {len(report.modified)}")
        _echo_info(f"  Missing:    {len(report.missing)}")
        _echo_info(f"  New:        {len(report.new_files)}")

        if report.modified:
            _echo_info("\nModified files:")
            for m in report.modified:
                _echo_info(
                    f"  {m.path}  expected: {m.expected_hash[:12]}... "
                    f"actual: {m.actual_hash[:12]}...  "
                    f"(size: {m.expected_size} -> {m.actual_size})"
                )

        if report.missing:
            _echo_info("\nMissing files:")
            for p in report.missing:
                _echo_info(f"  {p}")

        if report.new_files:
            _echo_info("\nNew files (not in manifest):")
            for p in report.new_files:
                _echo_info(f"  {p}")

        if report.errors:
            _echo_info("\nErrors:")
            for e in report.errors:
                _echo_info(f"  {e}")

        if report.has_discrepancies:
            total = len(report.modified) + len(report.missing) + len(report.new_files)
            click.echo(f"\nResult: INTEGRITY VIOLATION — {total} discrepancies found", err=True)
        else:
            _echo_info("\nResult: INTEGRITY OK — all files match baseline")

        if output_path:
            try:
                _write_integrity_report(report, Path(output_path))
            except (OSError, TypeError, ValueError) as exc:
                click.echo(click.style(f"Cannot write report: {exc}", fg="red"), err=True)
                sys.exit(1)
            _echo_info(f"Results saved to {output_path}")

        if resolved_write:
            _echo_info(f"State saved to {resolved_write}")

    if strict and report.has_discrepancies:
        sys.exit(1)


def _write_integrity_report(report, path: Path) -> None:
    """Write an IntegrityReport as JSON or CSV."""
    from dataclasses import asdict

    if path.suffix.lower() == ".csv":
        fieldnames = ["status", "path", "expected_hash", "actual_hash", "expected_size", "actual_size"]
        with path.open("w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames)
            writer.writeheader()
            for m in report.modified:
                writer.writerow({
                    "status": "modified",
                    "path": m.path,
                    "expected_hash": m.expected_hash,
                    "actual_hash": m.actual_hash,
                    "expected_size": m.expected_size,
                    "actual_size": m.actual_size,
                })
            for p in report.missing:
                writer.writerow({"status": "missing", "path": p, "expected_hash": "", "actual_hash": "", "expected_size": "", "actual_size": ""})
            for p in report.new_files:
                writer.writerow({"status": "new", "path": p, "expected_hash": "", "actual_hash": "", "expected_size": "", "actual_size": ""})
    else:
        data = asdict(report)
        path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")


# ─── census update ────────────────────────────────────────────────────────

@main.command(name="update")
@click.argument("manifest_path", type=click.Path())
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt (non-interactive mode).")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output (implies --yes).")
@click.pass_context
def update_cmd(
    ctx: click.Context,
    manifest_path: str,
    yes: bool,
    json_output: bool,
) -> None:
    """Update manifest baseline to match current filesystem state.

    Runs an integrity check against the manifest, then applies all detected
    changes: removes missing files, re-hashes modified files, and adds new
    files.  New entries are added with attested=False — run
    ``census scan --resume`` afterward to attest new hashes.

    This is the AIDE ``--update`` equivalent: detect → review → accept.

    \b
    Examples:
        census update manifest.db              # interactive confirmation
        census update manifest.db --yes        # non-interactive (cron/CI)
        census update manifest.db --json       # machine-readable output
        census update manifest.db -y -q        # silent update
    """
    from .evidence import integrity_check
    from .update import update_manifest_from_integrity

    t0 = time.monotonic()
    verbose = ctx.obj.get("verbose", False)
    quiet = ctx.obj.get("quiet", False)
    show_progress = not verbose and not json_output and not quiet

    resolved = _resolve_manifest_path(Path(manifest_path))
    if not resolved.exists():
        raise click.BadParameter(f"Manifest not found: {manifest_path}")

    manifest = _load_manifest(resolved)
    try:
        report = integrity_check(manifest, show_progress=show_progress)

        if not report.has_discrepancies:
            if json_output:
                click.echo(json.dumps(_with_meta({
                    "status": "up_to_date",
                    "removed": 0, "updated": 0, "added": 0,
                }, time.monotonic() - t0), indent=2, default=str))
            else:
                _echo_info("Manifest is up-to-date — no changes to apply.")
            return

        modified_count = len(report.modified)
        missing_count = len(report.missing)
        new_count = len(report.new_files)

        if not json_output and not yes:
            _echo_info(f"\nIntegrity check found {modified_count + missing_count + new_count} discrepancies:")
            if missing_count:
                _echo_info(f"  Remove:  {missing_count} missing file(s) from manifest")
            if modified_count:
                _echo_info(f"  Update:  {modified_count} modified file(s)")
            if new_count:
                _echo_info(f"  Add:     {new_count} new file(s)")
            _echo_info("")
            if not click.confirm("Apply these changes to the manifest?"):
                _echo_info("Aborted.")
                sys.exit(1)

        result = update_manifest_from_integrity(
            manifest, report, show_progress=show_progress,
        )

        try:
            manifest.save(resolved)
        except Exception as exc:
            click.echo(click.style(f"Cannot save manifest: {exc}", fg="red"), err=True)
            sys.exit(1)

        _audit("update", {
            "manifest": str(resolved),
        }, {
            "removed": result.removed,
            "updated": result.updated,
            "added": result.added,
            "errors": len(result.errors),
        })

        if json_output:
            data = _with_meta({
                "status": "updated",
                "manifest": str(resolved),
                "removed": result.removed,
                "updated": result.updated,
                "added": result.added,
                "errors": result.errors,
            }, time.monotonic() - t0)
            click.echo(json.dumps(data, indent=2, default=str))
        else:
            _echo_info(f"\nManifest updated:")
            _echo_info(f"  Removed:  {result.removed}")
            _echo_info(f"  Updated:  {result.updated}")
            _echo_info(f"  Added:    {result.added}")
            if result.errors:
                _echo_info(f"  Errors:   {len(result.errors)}")
                for e in result.errors:
                    click.echo(f"    {e}", err=True)
            _echo_info(f"Completed in {time.monotonic() - t0:.1f}s")
            if result.added or result.updated:
                _echo_info("\nNew/modified entries are unattested. Run:")
                _echo_info(f"  census scan {report.root_dir} --resume --manifest {resolved}")

        if result.errors:
            sys.exit(1)

    finally:
        manifest.close()


@main.command(name="verify-manifest")
@click.argument("manifest_path", type=click.Path())
@click.option("--detailed", is_flag=True, help="Fetch enriched data (source, level) per hash. Requires API key.")
@click.option("--strict", is_flag=True, help="Exit code 1 if any hash is not attested.")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@click.option("--format", "output_format", type=click.Choice(["text", "json", "jsonl"]), default="text", help="Output format.")
@click.option("--output", "-o", "output_path", default=None, type=click.Path(), help="Save report to file (.json or .csv).")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
@click.pass_context
def verify_manifest_cmd(
    ctx: click.Context,
    manifest_path: str,
    detailed: bool,
    strict: bool,
    json_output: bool,
    output_format: str,
    output_path: str | None,
    api_key: str | None,
    base_url: str | None,
) -> None:
    """Verify all manifest hashes against the CertiSigma registry.

    Full-chain verification: checks that every hash in the manifest has a
    corresponding attestation in the CertiSigma registry.  This completes
    the trust chain: file ↔ manifest ↔ registry.

    \b
    Examples:
        census verify-manifest inventory.db
        census verify-manifest inventory.db --strict
        census verify-manifest inventory.db --detailed --json
        census verify-manifest inventory.db -o report.csv
    """
    from .evidence import verify_manifest

    t0 = time.monotonic()

    if output_format != "text":
        json_output = output_format in ("json", "jsonl")
    elif json_output:
        output_format = "json"

    verbose = ctx.obj.get("verbose", False)
    quiet = ctx.obj.get("quiet", False)
    show_progress = not verbose and not json_output and not quiet

    resolved = _resolve_manifest_path(Path(manifest_path))
    if not resolved.exists():
        raise click.BadParameter(f"Manifest not found: {manifest_path}")

    client = _get_client(api_key, base_url)

    try:
        with _load_manifest(resolved) as manifest:
            report = verify_manifest(
                manifest, client,
                manifest_path=str(resolved),
                show_progress=show_progress,
                detailed=detailed,
            )
    except Exception as exc:
        if json_output:
            click.echo(json.dumps({"error": str(exc)}, indent=2, default=str))
        else:
            click.echo(click.style(f"Error: {exc}", fg="red"), err=True)
        sys.exit(1)

    _audit("verify-manifest", params={"manifest": str(resolved), "detailed": detailed}, result={
        "total": report.total, "verified": report.verified,
        "not_found": report.not_found, "errors": report.errors,
    })

    if output_path and output_format == "jsonl":
        click.echo("Note: --output is ignored when --format jsonl is set.", err=True)
    elif output_path:
        try:
            _write_verify_manifest_report(report, Path(output_path))
        except (OSError, TypeError, ValueError) as exc:
            if json_output:
                click.echo(json.dumps({"error": f"Cannot write report: {exc}"}, indent=2, default=str))
            else:
                click.echo(click.style(f"Cannot write report: {exc}", fg="red"), err=True)
            sys.exit(1)

    if output_format == "jsonl":
        from dataclasses import asdict
        items = [asdict(e) for e in report.entries]
        _emit_jsonl(items, time.monotonic() - t0, extra_summary={
            "total": report.total,
            "verified": report.verified,
            "not_found": report.not_found,
            "errors": report.errors,
        })
    elif json_output:
        from dataclasses import asdict
        click.echo(json.dumps(_with_meta(asdict(report), time.monotonic() - t0), indent=2, default=str))
    else:
        _echo_info(f"Manifest verification: {report.manifest_path}")
        _echo_info(f"Root: {report.root_dir}")
        _echo_info("")
        _echo_info(f"  Total:      {report.total}")
        _echo_info(f"  Verified:   {report.verified}")
        _echo_info(f"  Not found:  {report.not_found}")
        _echo_info(f"  Errors:     {report.errors}")

        if report.not_found > 0:
            _echo_info("\nNot attested:")
            not_found = [e for e in report.entries if not e.verified and not e.error]
            for e in not_found[:20]:
                _echo_info(f"  {e.hash_hex[:16]}...  {e.path}")
            if len(not_found) > 20:
                _echo_info(f"  ... and {len(not_found) - 20} more")

        if report.errors > 0:
            click.echo("\nErrors:", err=True)
            error_entries = [e for e in report.entries if e.error]
            for e in error_entries[:10]:
                click.echo(f"  {e.path}: {e.error}", err=True)

        if report.has_discrepancies:
            click.echo(
                click.style(f"\nResult: {report.not_found} not attested, {report.errors} errors", fg="red"),
                err=True,
            )
        else:
            _echo_info(click.style(f"\nResult: ALL {report.verified} hashes verified", fg="green"))

        if output_path:
            _echo_info(f"Report saved to {output_path}")

    if strict and report.has_discrepancies:
        sys.exit(1)


def _write_verify_manifest_report(report, path: Path) -> None:
    """Write a ManifestVerifyReport as JSON or CSV."""
    from dataclasses import asdict

    if path.suffix.lower() == ".csv":
        fieldnames = ["status", "hash_hex", "path", "level", "attestation_id", "timestamp", "source", "error"]
        with path.open("w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames)
            writer.writeheader()
            for e in report.entries:
                status = "verified" if e.verified else ("error" if e.error else "not_found")
                writer.writerow({
                    "status": status,
                    "hash_hex": e.hash_hex,
                    "path": e.path,
                    "level": e.level or "",
                    "attestation_id": e.attestation_id or "",
                    "timestamp": e.timestamp or "",
                    "source": e.source or "",
                    "error": e.error or "",
                })
    else:
        data = asdict(report)
        path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")


@main.command(name="report")
@click.argument("manifest_path", type=click.Path())
@click.option("--output", "-o", "output_path", required=True, type=click.Path(), help="Output file (.html, .pdf, or .zip with --bundle).")
@click.option("--evidence", is_flag=True, help="Fetch T0/T1/T2 evidence chain for attested files.")
@click.option("--integrity", "run_integrity", is_flag=True, help="Run integrity check and include results.")
@click.option("--bundle", is_flag=True, help="Generate evidence bundle (ZIP with report + OTS proofs + checksums).")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
@click.pass_context
def report_cmd(
    ctx: click.Context,
    manifest_path: str,
    output_path: str,
    evidence: bool,
    run_integrity: bool,
    bundle: bool,
    api_key: str | None,
    base_url: str | None,
) -> None:
    """Generate a forensic report from a manifest.

    Output format is auto-detected from the file extension:
    .html for HTML (always available), .pdf for PDF (requires fpdf2),
    .zip with --bundle for a self-contained evidence package.

    \b
    Examples:
        census report manifest.db -o report.html
        census report manifest.db -o report.pdf --evidence
        census report manifest.db -o report.pdf --integrity
        census report manifest.db -o bundle.zip --bundle --evidence
    """
    from .evidence import integrity_check, verify_hash
    from .report import (
        generate_evidence_bundle,
        generate_html_report,
        generate_pdf_report,
    )

    verbose = ctx.obj.get("verbose", False)
    quiet = ctx.obj.get("quiet", False)
    show_progress = not verbose and not quiet

    resolved = _resolve_manifest_path(Path(manifest_path))
    if not resolved.exists():
        raise click.BadParameter(f"Manifest not found: {manifest_path}")

    with _load_manifest(resolved) as manifest:
        client = None
        evidence_data = None
        if evidence:
            client = _get_client(api_key, base_url)
            evidence_data = []
            attested_entries = [e for e in manifest.iter_entries() if e.attested and e.attestation_id]
            with _progress(attested_entries, label="Fetching evidence", enabled=show_progress) as bar:
                for entry in bar:
                    try:
                        detail = verify_hash(client, entry.hash_hex)
                        evidence_data.append(detail)
                    except Exception as exc:
                        logger.warning("Could not verify %s: %s", entry.hash_hex[:16], exc)
            _echo_info(f"Fetched evidence for {len(evidence_data)} attestations")

        integrity_report = None
        if run_integrity:
            integrity_report = integrity_check(manifest, show_progress=show_progress)
            if integrity_report.has_discrepancies:
                total = len(integrity_report.modified) + len(integrity_report.missing) + len(integrity_report.new_files)
                _echo_info(f"Integrity check: {total} discrepancies found")
            else:
                _echo_info("Integrity check: all files match baseline")

        out = Path(output_path)
        ext = out.suffix.lower()

        try:
            if bundle or ext == ".zip":
                if bundle and ext != ".zip":
                    logger.warning("--bundle produces a ZIP archive; consider using .zip extension")
                content = generate_evidence_bundle(
                    manifest, client=client,
                    evidence=evidence_data, integrity=integrity_report,
                )
                out.write_bytes(content)

            elif ext == ".pdf":
                try:
                    content = generate_pdf_report(
                        manifest, evidence=evidence_data, integrity=integrity_report,
                    )
                    out.write_bytes(content)
                except ImportError:
                    click.echo("Error: fpdf2 is required for PDF reports.", err=True)
                    click.echo("Install with: pip install certisigma-census[report]", err=True)
                    sys.exit(1)

            else:
                content = generate_html_report(
                    manifest, evidence=evidence_data, integrity=integrity_report,
                )
                out.write_text(content, encoding="utf-8")
        except (OSError, TypeError, ValueError) as exc:
            click.echo(click.style(f"Cannot write report: {exc}", fg="red"), err=True)
            sys.exit(1)

        _echo_info(f"Report saved to {output_path}")


@main.command(name="diff")
@click.argument("base_manifest", type=click.Path())
@click.argument("target_manifest", type=click.Path())
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@click.option("--format", "output_format", type=click.Choice(["text", "json", "jsonl"]), default="text", help="Output format.")
@click.option("--output", "-o", "output_path", default=None, type=click.Path(), help="Save report (JSON, CSV, or HTML by extension).")
@click.option("--summary", is_flag=True, help="Show only summary counts, no file details.")
@click.pass_context
def diff_cmd(
    ctx: click.Context,
    base_manifest: str,
    target_manifest: str,
    json_output: bool,
    output_format: str,
    output_path: str | None,
    summary: bool,
) -> None:
    """Compare two manifests and show differences.

    Categorizes files as added, removed, or modified.  Exit code uses
    an AIDE-style bitmask: 1=added, 2=removed, 4=modified (OR'd together).
    Exit code 0 means no changes.

    \b
    Examples:
        census diff baseline.db current.db
        census diff baseline.db current.db --json
        census diff baseline.db current.db -o diff.html
        census diff baseline.db current.db --summary
    """
    from .diff import diff_manifests, generate_diff_html

    t0 = time.monotonic()

    if output_format != "text":
        json_output = output_format in ("json", "jsonl")
    elif json_output:
        output_format = "json"

    base_path = _resolve_manifest_path(Path(base_manifest))
    if not base_path.exists():
        raise click.BadParameter(f"Base manifest not found: {base_manifest}")
    target_path = _resolve_manifest_path(Path(target_manifest))
    if not target_path.exists():
        raise click.BadParameter(f"Target manifest not found: {target_manifest}")

    with _load_manifest(base_path) as base, _load_manifest(target_path) as target:
        result = diff_manifests(base, target)

    if output_format == "jsonl":
        if output_path:
            click.echo("Note: --output is ignored when --format jsonl is set.", err=True)
        from dataclasses import asdict
        items = [asdict(e) for e in result.added + result.removed + result.modified]
        _emit_jsonl(items, time.monotonic() - t0, extra_summary={
            "base_root": result.base_root,
            "target_root": result.target_root,
            "added": len(result.added),
            "removed": len(result.removed),
            "modified": len(result.modified),
            "unchanged_count": result.unchanged_count,
        })
    elif json_output:
        if output_path:
            try:
                _write_diff_report(result, Path(output_path))
            except (OSError, TypeError, ValueError) as exc:
                click.echo(json.dumps({"error": f"Cannot write report: {exc}"}, indent=2, default=str))
                sys.exit(1)
        from dataclasses import asdict
        click.echo(json.dumps(_with_meta(asdict(result), time.monotonic() - t0), indent=2, default=str))
    else:
        _echo_info(f"Base:     {result.base_root}")
        _echo_info(f"Target:   {result.target_root}")
        _echo_info("")
        _echo_info(f"  Added:      {len(result.added)}")
        _echo_info(f"  Removed:    {len(result.removed)}")
        _echo_info(f"  Modified:   {len(result.modified)}")
        _echo_info(f"  Unchanged:  {result.unchanged_count}")

        if not summary:
            if result.added:
                _echo_info("\nAdded files:")
                for e in result.added:
                    size = e.new_size if e.new_size is not None else 0
                    _echo_info(f"  + {e.path}  ({size:,} bytes)")

            if result.removed:
                _echo_info("\nRemoved files:")
                for e in result.removed:
                    size = e.old_size if e.old_size is not None else 0
                    _echo_info(f"  - {e.path}  ({size:,} bytes)")

            if result.modified:
                _echo_info("\nModified files:")
                for e in result.modified:
                    old_s = e.old_size if e.old_size is not None else 0
                    new_s = e.new_size if e.new_size is not None else 0
                    _echo_info(
                        f"  ~ {e.path}  "
                        f"{e.old_hash[:12] if e.old_hash else '?'}... -> "
                        f"{e.new_hash[:12] if e.new_hash else '?'}...  "
                        f"({old_s:,} -> {new_s:,} bytes)"
                    )

        if result.has_changes:
            _echo_info(f"\nResult: {len(result.added) + len(result.removed) + len(result.modified)} differences found")
        else:
            _echo_info("\nResult: manifests are identical")

        if output_path:
            try:
                _write_diff_report(result, Path(output_path))
            except (OSError, TypeError, ValueError) as exc:
                click.echo(click.style(f"Cannot write report: {exc}", fg="red"), err=True)
                sys.exit(1)
            _echo_info(f"Report saved to {output_path}")

    sys.exit(result.exit_code)


def _write_diff_report(result, path: Path) -> None:
    """Write a DiffResult as JSON, CSV, or HTML."""
    ext = path.suffix.lower()
    if ext == ".html":
        from .diff import generate_diff_html
        content = generate_diff_html(result)
        path.write_text(content, encoding="utf-8")
    elif ext == ".csv":
        fieldnames = ["status", "path", "old_hash", "new_hash", "old_size", "new_size"]
        with path.open("w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames)
            writer.writeheader()
            for entry in result.added + result.removed + result.modified:
                writer.writerow({
                    "status": entry.status,
                    "path": entry.path,
                    "old_hash": entry.old_hash or "",
                    "new_hash": entry.new_hash or "",
                    "old_size": entry.old_size if entry.old_size is not None else "",
                    "new_size": entry.new_size if entry.new_size is not None else "",
                })
    else:
        from dataclasses import asdict
        data = asdict(result)
        path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")


@main.command(name="hash")
@click.argument("target", required=False, type=click.Path(resolve_path=True))
@click.option("--stdin", "use_stdin", is_flag=True, help="Read data from stdin instead of a file.")
@click.option("--verify", "expected_hash", default=None, help="Verify computed hash matches this SHA-256 value.")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@click.pass_context
def hash_cmd(
    ctx: click.Context,
    target: str | None,
    use_stdin: bool,
    expected_hash: str | None,
    json_output: bool,
) -> None:
    """Compute SHA-256 hash of a file, directory, or stdin.

    Standalone utility — no manifest, no API calls.

    \b
    Examples:
        census hash document.pdf
        census hash /path/to/files
        census hash document.pdf --verify a1b2c3...
        census hash /data --json
        echo "data" | census hash --stdin
        cat file.bin | census hash --stdin --verify a1b2c3...
    """
    t0 = time.monotonic()
    if use_stdin and target:
        raise click.UsageError("Cannot use --stdin with a file/directory argument.")
    if not use_stdin and not target:
        raise click.UsageError("Provide a file/directory argument or use --stdin.")

    if expected_hash:
        expected = expected_hash.lower().strip()
        if not re.fullmatch(r"[0-9a-f]{64}", expected):
            raise click.BadParameter(
                f"Invalid SHA-256 hash: expected 64 hex characters, got {len(expected)}."
            )
    else:
        expected = None

    if use_stdin:
        import hashlib

        h = hashlib.sha256()
        stream = click.get_binary_stream("stdin")
        while True:
            chunk = stream.read(65536)
            if not chunk:
                break
            h.update(chunk)
        hex_digest = h.hexdigest()
        results = [{"path": "<stdin>", "hash": hex_digest, "size": None}]
    else:
        from certisigma import hash_file as sdk_hash_file

        target_path = Path(target)
        if not target_path.exists():
            raise click.BadParameter(f"Path does not exist: {target}")

        if expected and not target_path.is_file():
            raise click.UsageError("--verify requires a single file, not a directory.")

        if target_path.is_file():
            try:
                file_hash = sdk_hash_file(str(target_path))
                results = [{"path": str(target_path), "hash": file_hash, "size": target_path.stat().st_size}]
            except Exception as exc:
                raise click.ClickException(f"Cannot hash {target_path}: {exc}") from exc
        else:
            from .scanner import walk_files
            results = []
            files = sorted(walk_files(target_path))
            for fpath in files:
                try:
                    file_hash = sdk_hash_file(str(fpath))
                    results.append({"path": str(fpath), "hash": file_hash, "size": fpath.stat().st_size})
                except Exception as exc:
                    logger.warning("Cannot hash %s: %s", fpath, exc)

    if json_output:
        click.echo(json.dumps(_with_meta(
            {"files": results}, time.monotonic() - t0,
        ), indent=2, default=str))
        if expected and len(results) == 1:
            if results[0]["hash"] != expected:
                sys.exit(1)
        return

    for r in results:
        click.echo(f"{r['hash']}  {r['path']}")

    if expected:
        if results[0]["hash"] == expected:
            click.echo("Verification: OK")
        else:
            click.echo(f"Verification: MISMATCH (expected {expected[:16]}...)", err=True)
            sys.exit(1)


@main.command(name="track")
@click.argument("attestation_id")
@click.option("--poll", is_flag=True, help="Poll until T2 (Bitcoin) level is reached.")
@click.option("--poll-interval", default=60, type=click.IntRange(min=1), help="Seconds between polls (default: 60).")
@click.option("--timeout", default=3600, type=click.IntRange(min=1), help="Max seconds to poll (default: 3600).")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
def track_cmd(
    attestation_id: str,
    poll: bool,
    poll_interval: int,
    timeout: int,
    json_output: bool,
    api_key: str | None,
    base_url: str | None,
) -> None:
    """Track attestation status and T0/T1/T2 progression.

    Shows the current proof level for an attestation.  Use --poll to
    wait until the attestation reaches T2 (Bitcoin anchoring).

    \b
    Examples:
        census track att_12345
        census track att_12345 --poll
        census track att_12345 --json
        census track att_12345 --poll --timeout 7200
    """
    from .retry import retry_api_call

    t0 = time.monotonic()
    client = _get_client(api_key, base_url)
    start = time.monotonic()

    while True:
        try:
            status = retry_api_call(client.status, attestation_id)
        except Exception as exc:
            if json_output:
                click.echo(json.dumps({"error": str(exc)}, indent=2, default=str))
            else:
                click.echo(f"Error: {exc}", err=True)
            sys.exit(1)

        if json_output:
            data = {
                "id": status.id,
                "hash_hex": status.hash_hex,
                "level": status.level,
                "t0_timestamp": status.t0_timestamp,
                "t1_timestamp": status.t1_timestamp,
                "t2_timestamp": status.t2_timestamp,
                "t2_bitcoin_block": status.t2_bitcoin_block,
                "signature_available": status.signature_available,
                "tsa_available": status.tsa_available,
                "merkle_proof_available": status.merkle_proof_available,
            }
            click.echo(json.dumps(_with_meta(data, time.monotonic() - t0), indent=2, default=str))
        else:
            _echo_info(f"ID:         {status.id}")
            _echo_info(f"Hash:       {status.hash_hex}")
            _echo_info(f"Level:      {status.level}")
            _echo_info(f"T0:         {status.t0_timestamp}")
            t1 = status.t1_timestamp or "pending"
            _echo_info(f"T1 (TSA):   {t1}")
            t2 = status.t2_timestamp or "pending"
            _echo_info(f"T2 (BTC):   {t2}")
            if status.t2_bitcoin_block:
                _echo_info(f"BTC Block:  {status.t2_bitcoin_block}")

        if not poll or status.level == "T2":
            break

        elapsed = time.monotonic() - start
        if elapsed + poll_interval > timeout:
            if not json_output:
                click.echo(f"\nTimeout ({timeout}s) reached. Current level: {status.level}", err=True)
            sys.exit(2)

        if not json_output:
            _echo_info(f"\nWaiting {poll_interval}s for next check (level: {status.level})...")
        time.sleep(poll_interval)


@main.command(name="config")
@click.argument("action", type=click.Choice(["show", "init", "paths"]))
@click.option("--project", is_flag=True, help="Act on project-level .census.toml instead of user config.")
def config_cmd(action: str, project: bool) -> None:
    """Manage Census configuration.

    \b
    Actions:
        show    Display current effective config (merged)
        init    Create a template config file
        paths   Show config file locations
    """
    from .config import config_paths, init_config, load_config, mask_sensitive

    if action == "show":
        cfg = load_config()
        masked = mask_sensitive(cfg)
        click.echo(json.dumps(masked, indent=2, default=str))

    elif action == "init":
        if project:
            path = Path.cwd() / ".census.toml"
        else:
            paths = config_paths()
            path = paths["user"]
            if path is None:
                path = Path.home() / ".config" / "certisigma-census" / "config.toml"

        try:
            init_config(path)
        except FileExistsError:
            _echo_info(f"Config already exists: {path}")
            return
        except OSError as exc:
            click.echo(click.style(f"Cannot write config: {exc}", fg="red"), err=True)
            sys.exit(1)
        _echo_info(f"Config template created: {path}")

    elif action == "paths":
        paths = config_paths()
        for label, p in paths.items():
            exists = p.exists() if p else False
            status_str = "found" if exists else "not found"
            click.echo(f"  {label:10s} {p}  ({status_str})")


@main.command(name="merge")
@click.argument("manifests", nargs=-1, required=True, type=click.Path())
@click.option("--output", "-o", "output_path", required=True, type=click.Path(), help="Output manifest path.")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON summary.")
@click.pass_context
def merge_cmd(
    ctx: click.Context,
    manifests: tuple[str, ...],
    output_path: str,
    json_output: bool,
) -> None:
    """Merge multiple manifests into one.

    Combines file entries from all input manifests.  When the same hash
    appears in multiple manifests, attestation state is preserved (attested
    wins over unattested).  When different hashes share the same path,
    the entry from the later manifest takes precedence.

    \b
    Examples:
        census merge server1.db server2.db -o combined.db
        census merge scans/*.db -o full-inventory.db --json
    """
    from .manifest import Manifest

    t0 = time.monotonic()
    if len(manifests) < 2:
        raise click.UsageError("At least two manifests are required.")

    out = Path(output_path)
    merged = Manifest(root_dir="merged")
    total_merged = 0

    try:
        for mp in manifests:
            resolved = _resolve_manifest_path(Path(mp))
            if not resolved.exists():
                raise click.BadParameter(f"Manifest not found: {mp}")
            with _load_manifest(resolved) as source:
                count = merged.merge(source)
                total_merged += count
                if not json_output:
                    _echo_info(f"  + {resolved.name}: {source.total} entries ({count} new/updated)")

        try:
            merged.save(out)
        except Exception as exc:
            if json_output:
                click.echo(json.dumps({"error": f"Cannot save merged manifest: {exc}"}, indent=2, default=str))
            else:
                click.echo(click.style(f"Cannot save merged manifest: {exc}", fg="red"), err=True)
            sys.exit(1)
        total_entries = merged.total
    finally:
        merged.close()

    summary = {
        "output": str(out),
        "sources": len(manifests),
        "total_entries": total_entries,
        "merged_entries": total_merged,
    }

    _audit("merge", {"sources": len(manifests)}, {"total_entries": total_entries, "merged_entries": total_merged})

    if json_output:
        click.echo(json.dumps(_with_meta(summary, time.monotonic() - t0), indent=2, default=str))
    else:
        _echo_info(f"\nMerged {len(manifests)} manifests → {out} ({total_entries} entries)")


@main.command(name="doctor")
@click.option("--manifest", "manifest_path", default=None, type=click.Path(), help="Check health of a specific manifest file.")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
def doctor_cmd(
    manifest_path: str | None,
    json_output: bool,
    api_key: str | None,
    base_url: str | None,
) -> None:
    """Run diagnostic checks on Census configuration and connectivity.

    Verifies Python version, SDK, optional dependencies, config files,
    API connectivity, API key validity, inotify limits, and optionally
    manifest file health.

    \b
    Examples:
        census doctor
        census doctor --manifest inventory.db
        census doctor --json
    """
    from .doctor import run_doctor

    t0 = time.monotonic()
    mpath = Path(manifest_path) if manifest_path else None
    if mpath:
        mpath = _resolve_manifest_path(mpath)

    report = run_doctor(api_key=api_key, base_url=base_url, manifest_path=mpath)

    if json_output:
        from dataclasses import asdict
        click.echo(json.dumps(_with_meta(asdict(report), time.monotonic() - t0), indent=2, default=str))
    else:
        symbols = {"ok": "✓", "warn": "!", "fail": "✗"}
        colors = {"ok": "green", "warn": "yellow", "fail": "red"}
        for check in report.checks:
            sym = symbols.get(check.status, "?")
            color = colors.get(check.status, "white")
            is_problem = check.status in ("warn", "fail")
            if is_problem:
                click.echo(click.style(f"  {sym} ", fg=color) + check.message, err=True)
            else:
                _echo_info(click.style(f"  {sym} ", fg=color) + check.message)
            if check.detail:
                if is_problem:
                    click.echo(f"    {check.detail}", err=True)
                else:
                    _echo_info(f"    {check.detail}")

        _echo_info("")
        if report.fail_count == 0 and report.warn_count == 0:
            _echo_info(click.style("All checks passed.", fg="green"))
        elif report.fail_count == 0:
            click.echo(click.style(
                f"All checks passed with {report.warn_count} warning(s).",
                fg="yellow",
            ), err=True)
        else:
            click.echo(
                f"{report.ok_count} ok, {report.warn_count} warnings, "
                f"{report.fail_count} failures",
                err=True,
            )

    if report.fail_count > 0:
        sys.exit(1)


@main.command(name="completion")
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
def completion_cmd(shell: str) -> None:
    """Generate shell completion script.

    \b
    Usage:
        eval "$(census completion bash)"
        eval "$(census completion zsh)"
        census completion fish | source
    """
    from click.shell_completion import get_completion_class

    comp_cls = get_completion_class(shell)
    if comp_cls is None:
        raise click.ClickException(f"Unsupported shell: {shell}")
    comp = comp_cls(main, {}, "census", "_CENSUS_COMPLETE")
    click.echo(comp.source())


@main.command(name="audit-log")
@click.argument("action", type=click.Choice(["show", "verify", "clear"]))
@click.option("--log-path", default=None, type=click.Path(), help="Override audit log file path.")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@click.option("--last", "last_n", default=None, type=int, help="Show only the last N entries (for 'show').")
def audit_log_cmd(action: str, log_path: str | None, json_output: bool, last_n: int | None) -> None:
    """Manage the tamper-evident audit log.

    Census logs every operation (scan, compare, verify, annotate, ...) to
    an append-only JSONL file.  Each entry includes the SHA-256 hash of the
    previous entry, forming a chain that detects tampering.

    \b
    Actions:
        show    Display audit log entries
        verify  Check the hash chain integrity
        clear   Delete the audit log file

    \b
    Examples:
        census audit-log show
        census audit-log show --last 10 --json
        census audit-log verify
    """
    from .audit_log import iter_entries, verify_chain

    lp = Path(log_path) if log_path else None

    if action == "show":
        try:
            if last_n is not None and last_n > 0:
                from collections import deque
                entries = list(deque(iter_entries(lp), maxlen=last_n))
            else:
                entries = list(iter_entries(lp))
        except (OSError, ValueError) as exc:
            if json_output:
                click.echo(json.dumps({"error": f"Cannot read audit log: {exc}"}, indent=2, default=str))
            else:
                click.echo(click.style(f"Cannot read audit log: {exc}", fg="red"), err=True)
            sys.exit(1)
        if json_output:
            from dataclasses import asdict
            click.echo(json.dumps([asdict(e) for e in entries], indent=2, default=str))
        else:
            if not entries:
                click.echo("Audit log is empty.")
                return
            for e in entries:
                click.echo(
                    f"[{e.timestamp}] {e.action:12s} "
                    f"hash: {e.entry_hash[:12]}... "
                    f"prev: {e.prev_hash[:12]}..."
                )
                if e.params:
                    for k, v in e.params.items():
                        click.echo(f"    {k}: {v}")

    elif action == "verify":
        try:
            result = verify_chain(lp)
        except (OSError, ValueError) as exc:
            if json_output:
                click.echo(json.dumps({"error": f"Cannot read audit log: {exc}"}, indent=2, default=str))
            else:
                click.echo(click.style(f"Cannot read audit log: {exc}", fg="red"), err=True)
            sys.exit(1)
        if json_output:
            from dataclasses import asdict
            click.echo(json.dumps(asdict(result), indent=2, default=str))
        else:
            if result.total == 0:
                click.echo("Audit log is empty — nothing to verify.")
            elif result.intact:
                click.echo(
                    click.style(
                        f"Chain intact: {result.valid}/{result.total} entries verified.",
                        fg="green",
                    )
                )
            else:
                click.echo(
                    click.style(f"CHAIN BROKEN: {result.error}", fg="red"),
                    err=True,
                )
                sys.exit(1)

    elif action == "clear":
        from .audit_log import _resolve_log_path
        resolved = _resolve_log_path(lp)
        try:
            deleted = resolved.exists()
            if deleted:
                resolved.unlink()
        except OSError as exc:
            if json_output:
                click.echo(json.dumps({"error": f"Cannot clear audit log: {exc}"}, indent=2, default=str))
            else:
                click.echo(click.style(f"Cannot clear audit log: {exc}", fg="red"), err=True)
            sys.exit(1)
        if json_output:
            click.echo(json.dumps({"cleared": deleted, "path": str(resolved)}, indent=2, default=str))
        elif deleted:
            _echo_info(f"Audit log deleted: {resolved}")
        else:
            click.echo("Audit log does not exist.")


@main.command(name="snapshot")
@click.argument("action", type=click.Choice(["create", "list", "diff", "delete"]))
@click.argument("args", nargs=-1)
@click.option("--manifest", "manifest_path", default=None, type=click.Path(), help="Manifest to snapshot (for 'create').")
@click.option("--snapshot-dir", default=None, type=click.Path(), help="Override snapshot directory.")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@click.pass_context
def snapshot_cmd(
    ctx: click.Context,
    action: str,
    args: tuple[str, ...],
    manifest_path: str | None,
    snapshot_dir: str | None,
    json_output: bool,
) -> None:
    """Manage named manifest snapshots for compliance baselines.

    Snapshots are timestamped copies of a manifest.  Use them to create
    baselines (e.g. 'Q1-2026') and later compare them with ``diff``.

    \b
    Actions:
        create <name>          Save a snapshot of the current manifest
        list                   List all available snapshots
        diff <name1> <name2>   Compare two snapshots (uses census diff)
        delete <name>          Remove a snapshot

    \b
    Examples:
        census snapshot create q1-baseline --manifest inventory.db
        census snapshot list --json
        census snapshot diff q1-baseline q2-baseline
        census snapshot delete q1-baseline
    """
    from .snapshot import (
        create_snapshot,
        delete_snapshot,
        get_snapshot_path,
        list_snapshots,
    )

    sdir = Path(snapshot_dir) if snapshot_dir else None

    if action == "create":
        if not args:
            raise click.UsageError("Usage: census snapshot create <name>")
        name = args[0]
        if not manifest_path:
            raise click.UsageError(
                "Specify --manifest when creating a snapshot."
            )
        mpath = _resolve_manifest_path(Path(manifest_path))
        if not mpath.exists():
            raise click.BadParameter(f"Manifest not found: {manifest_path}")
        try:
            info = create_snapshot(name, mpath, snapshot_dir=sdir)
        except ValueError as exc:
            raise click.BadParameter(str(exc))
        except FileExistsError as exc:
            raise click.ClickException(str(exc))
        except Exception as exc:
            raise click.ClickException(f"Snapshot I/O error: {exc}")
        if json_output:
            click.echo(json.dumps({
                "name": info.name,
                "path": str(info.path),
                "created_at": info.created_at,
                "size_bytes": info.size_bytes,
                "manifest_root": info.manifest_root,
            }, indent=2, default=str))
        else:
            _echo_info(f"Snapshot '{info.name}' created ({info.size_bytes:,} bytes)")

    elif action == "list":
        snaps = list_snapshots(snapshot_dir=sdir)
        if json_output:
            click.echo(json.dumps([{
                "name": s.name,
                "created_at": s.created_at,
                "size_bytes": s.size_bytes,
                "manifest_root": s.manifest_root,
            } for s in snaps], indent=2, default=str))
        else:
            if not snaps:
                click.echo("No snapshots found.")
            else:
                for s in snaps:
                    click.echo(
                        f"  {s.name:30s} {s.created_at}  "
                        f"({s.size_bytes:,} bytes) root: {s.manifest_root}"
                    )

    elif action == "diff":
        if len(args) < 2:
            raise click.UsageError(
                "Usage: census snapshot diff <name1> <name2>"
            )
        try:
            p1 = get_snapshot_path(args[0], snapshot_dir=sdir)
        except ValueError as exc:
            raise click.BadParameter(str(exc))
        if not p1:
            raise click.BadParameter(f"Snapshot not found: {args[0]}")
        try:
            p2 = get_snapshot_path(args[1], snapshot_dir=sdir)
        except ValueError as exc:
            raise click.BadParameter(str(exc))
        if not p2:
            raise click.BadParameter(f"Snapshot not found: {args[1]}")

        from .diff import diff_manifests

        with _load_manifest(p1) as base, _load_manifest(p2) as target:
            result = diff_manifests(base, target)

        if json_output:
            from dataclasses import asdict
            click.echo(json.dumps(asdict(result), indent=2, default=str))
        else:
            _echo_info(f"Snapshot '{args[0]}' vs '{args[1]}':")
            click.echo(f"  Added:      {len(result.added)}")
            click.echo(f"  Removed:    {len(result.removed)}")
            click.echo(f"  Modified:   {len(result.modified)}")
            click.echo(f"  Unchanged:  {result.unchanged_count}")

        sys.exit(result.exit_code)

    elif action == "delete":
        if not args:
            raise click.UsageError("Usage: census snapshot delete <name>")
        try:
            deleted = delete_snapshot(args[0], snapshot_dir=sdir)
        except ValueError as exc:
            raise click.BadParameter(str(exc))
        except OSError as exc:
            raise click.ClickException(f"Cannot delete snapshot: {exc}")
        if json_output:
            click.echo(json.dumps({"name": args[0], "deleted": deleted}, indent=2, default=str))
        elif deleted:
            _echo_info(f"Snapshot '{args[0]}' deleted.")
        else:
            _echo_info(f"Snapshot '{args[0]}' not found.")


@main.command(name="annotate")
@click.argument("attestation_id")
@click.option("--note", default=None, help="Free-text note for the attestation.")
@click.option("--tag", default=None, help="Tag label (e.g. case number, department).")
@click.option("--case-id", default=None, help="Forensic case identifier.")
@click.option("--source", default=None, help="Update the source label.")
@click.option("--delete", "do_delete", is_flag=True, help="Soft-delete metadata (GDPR right-to-erasure).")
@click.option("--encrypt", is_flag=True, help="Encrypt metadata client-side (AES-256-GCM) before sending.")
@click.option("--encryption-key", default=None, help="Hex AES-256 key (64 chars). Default: from config.")
@click.option("--decrypt", is_flag=True, help="After annotation, try to decrypt and display the stored metadata.")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
def annotate_cmd(
    attestation_id: str,
    note: str | None,
    tag: str | None,
    case_id: str | None,
    source: str | None,
    do_delete: bool,
    encrypt: bool,
    encryption_key: str | None,
    decrypt: bool,
    json_output: bool,
    api_key: str | None,
    base_url: str | None,
) -> None:
    """Annotate an attestation with forensic metadata.

    Attach notes, tags, or case identifiers to existing attestations
    via the CertiSigma API.  Use --encrypt for zero-knowledge mode
    where the server never sees plaintext metadata.

    \b
    Examples:
        census annotate att_123 --note "Evidence collected 2026-03-18"
        census annotate att_123 --tag "case-2026-001" --case-id "FR-2026-42"
        census annotate att_123 --note "Confidential" --encrypt
        census annotate att_123 --delete
    """
    from .annotate import annotate, decrypt_annotation, delete_annotation

    source = _validate_label(source, "--source")
    note = _validate_label(note, "--note")
    tag = _validate_label(tag, "--tag")
    case_id = _validate_label(case_id, "--case-id")

    if do_delete:
        result = delete_annotation(
            attestation_id, api_key=api_key, base_url=base_url,
        )
    else:
        if not any([note, tag, case_id, source]):
            raise click.UsageError(
                "Provide at least one of --note, --tag, --case-id, or --source."
            )
        result = annotate(
            attestation_id,
            api_key=api_key, base_url=base_url, source=source,
            note=note, tag=tag, case_id=case_id,
            encrypt=encrypt, encryption_key=encryption_key,
        )

    _audit(
        "annotate" if not do_delete else "annotate_delete",
        {"attestation_id": attestation_id, "encrypted": encrypt},
        {"success": result.success, "deleted": result.deleted},
    )

    if json_output:
        from dataclasses import asdict
        data = asdict(result)
        if decrypt and result.extra_data and not result.error:
            try:
                data["decrypted_extra_data"] = decrypt_annotation(
                    result.extra_data, encryption_key=encryption_key,
                )
            except Exception as exc:
                data["decrypt_error"] = str(exc)
        click.echo(json.dumps(data, indent=2, default=str))
        if not result.success:
            sys.exit(1)
    else:
        if result.error:
            click.echo(click.style(f"Error: {result.error}", fg="red"), err=True)
            sys.exit(1)
        if result.deleted:
            _echo_info(f"Metadata deleted for {result.attestation_id}")
        else:
            _echo_info(f"Annotation updated for {result.attestation_id}")
            if result.source:
                click.echo(f"  Source: {result.source}")
            if result.client_encrypted:
                click.echo("  Encrypted: yes (AES-256-GCM)")
            if decrypt and result.extra_data:
                try:
                    plain = decrypt_annotation(
                        result.extra_data, encryption_key=encryption_key,
                    )
                    click.echo(f"  Decrypted: {json.dumps(plain, default=str)}")
                except Exception as exc:
                    click.echo(f"  Cannot decrypt: {exc}", err=True)


# ─── census share ──────────────────────────────────────────────────────────

@main.command("share")
@click.argument("action", type=click.Choice(["create", "list", "revoke", "info"]))
@click.argument("args", nargs=-1)
@click.option("--expires", default="24h", help="Token lifetime (e.g. 30m, 24h, 7d). Default: 24h.")
@click.option("--recipient", default=None, help="Recipient label (e.g. 'Legal Dept').")
@click.option("--max-uses", default=None, type=int, help="Max number of uses for the token.")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
@click.option("--json", "json_output", is_flag=True, help="Output as JSON.")
@click.pass_context
def share_cmd(
    ctx: click.Context,
    action: str,
    args: tuple[str, ...],
    expires: str,
    recipient: str | None,
    max_uses: int | None,
    api_key: str | None,
    base_url: str | None,
    json_output: bool,
) -> None:
    """Forensic share tokens — create, list, revoke, inspect.

    Create time-limited, use-limited share tokens for chain-of-custody
    evidence sharing.

    Examples:\b
        census share create <att_id> [<att_id>...] --expires 24h --recipient "Legal"
        census share list --json
        census share info <token_id>
        census share revoke <token_id>
    """
    from .sharing import (
        create_share_token, list_share_tokens,
        revoke_share_token, get_share_token_info,
        ShareResult,
    )
    from dataclasses import asdict

    recipient = _validate_label(recipient, "--recipient")
    cfg = _get_config()
    api_key = api_key or cfg.get("api_key")
    base_url = base_url or cfg.get("base_url")

    if action == "create":
        if not args:
            raise click.UsageError("Provide at least one attestation ID.")
        result = create_share_token(
            list(args),
            api_key=api_key,
            base_url=base_url,
            expires_in=expires,
            recipient_label=recipient,
            max_uses=max_uses,
        )
        _audit("share_create", {
            "attestation_count": len(args),
            "recipient": recipient,
        }, {"success": result.success, "token_id": result.token_id})

        if json_output:
            click.echo(json.dumps(asdict(result), indent=2, default=str))
            if not result.success:
                sys.exit(1)
        else:
            if result.error:
                click.echo(click.style(f"Error: {result.error}", fg="red"), err=True)
                sys.exit(1)
            _echo_info(f"Share token created: {result.token_id}")
            click.echo(f"  Token:     {result.share_token}")
            click.echo(f"  Expires:   {result.expires_at}")
            if result.recipient_label:
                click.echo(f"  Recipient: {result.recipient_label}")
            if result.max_uses:
                click.echo(f"  Max uses:  {result.max_uses}")
            click.echo(f"  Covers {len(result.attestation_ids)} attestation(s)")

    elif action == "list":
        tokens = list_share_tokens(api_key=api_key, base_url=base_url)
        if json_output:
            click.echo(json.dumps([asdict(t) for t in tokens], indent=2, default=str))
        else:
            if not tokens:
                click.echo("No share tokens found.")
                return
            for t in tokens:
                status = click.style("active", fg="green") if t.active else click.style("inactive", fg="red")
                click.echo(f"  {t.token_id}  {status}  {t.created_at}  {len(t.attestation_ids)} att(s)")
                if t.recipient_label:
                    click.echo(f"    Recipient: {t.recipient_label}")

    elif action == "revoke":
        if not args:
            raise click.UsageError("Provide a token ID to revoke.")
        result = revoke_share_token(args[0], api_key=api_key, base_url=base_url)
        _audit("share_revoke", {"token_id": args[0]}, {"success": result.success})
        if json_output:
            click.echo(json.dumps(asdict(result), indent=2, default=str))
            if not result.success:
                sys.exit(1)
        else:
            if result.error:
                click.echo(click.style(f"Error: {result.error}", fg="red"), err=True)
                sys.exit(1)
            _echo_info(f"Token {args[0]} revoked.")

    elif action == "info":
        if not args:
            raise click.UsageError("Provide a token ID.")
        info = get_share_token_info(args[0], api_key=api_key, base_url=base_url)
        if isinstance(info, ShareResult):
            if json_output:
                click.echo(json.dumps(asdict(info), indent=2, default=str))
            else:
                click.echo(click.style(f"Error: {info.error}", fg="red"), err=True)
            sys.exit(1)
        if json_output:
            click.echo(json.dumps(asdict(info), indent=2, default=str))
        else:
            status = click.style("active", fg="green") if info.active else click.style("inactive", fg="red")
            click.echo(f"Token:      {info.token_id}  [{status}]")
            click.echo(f"Created:    {info.created_at}")
            click.echo(f"Expires:    {info.expires_at}")
            if info.recipient_label:
                click.echo(f"Recipient:  {info.recipient_label}")
            click.echo(f"Uses:       {info.use_count}" + (f" / {info.max_uses}" if info.max_uses else ""))
            click.echo(f"Attestations: {len(info.attestation_ids)}")


# ─── census tag ────────────────────────────────────────────────────────────

@main.command("tag")
@click.argument("action", type=click.Choice(["set", "get", "delete", "query"]))
@click.argument("args", nargs=-1)
@click.option("--tag", "-t", "tag_pairs", multiple=True, help="Tag as key=value (repeatable).")
@click.option("--encrypt", is_flag=True, help="Encrypt tag values client-side (AES-256-GCM).")
@click.option("--decrypt", is_flag=True, help="Decrypt encrypted tag values on get.")
@click.option("--encryption-key", default=None, help="Hex-encoded AES-256 key (64 chars).")
@click.option("--filter", "-f", "filters", multiple=True, help="Filter as key=value for query (repeatable, AND logic).")
@click.option("--limit", default=100, type=int, help="Max results for query. Default: 100.")
@click.option("--cursor", default=None, help="Pagination cursor for query.")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
@click.option("--json", "json_output", is_flag=True, help="Output as JSON.")
@click.pass_context
def tag_cmd(
    ctx: click.Context,
    action: str,
    args: tuple[str, ...],
    tag_pairs: tuple[str, ...],
    encrypt: bool,
    decrypt: bool,
    encryption_key: str | None,
    filters: tuple[str, ...],
    limit: int,
    cursor: str | None,
    api_key: str | None,
    base_url: str | None,
    json_output: bool,
) -> None:
    """Structured tagging — classify attestations with key-value tags.

    Examples:\b
        census tag set <att_id> -t department=legal -t case=2026-001
        census tag set <att_id> -t classification=confidential --encrypt
        census tag get <att_id> --json
        census tag delete <att_id> <tag_key>
        census tag query -f department=legal -f year=2026 --limit 50
    """
    from .tagging import (
        parse_tag_pair, set_tags, get_tags,
        delete_tag, query_tags,
    )
    from dataclasses import asdict

    cfg = _get_config()
    api_key = api_key or cfg.get("api_key")
    base_url = base_url or cfg.get("base_url")

    if action == "set":
        if not args:
            raise click.UsageError("Provide an attestation ID.")
        if not tag_pairs:
            raise click.UsageError("Provide at least one --tag key=value.")
        try:
            parsed = [parse_tag_pair(t) for t in tag_pairs]
        except ValueError as exc:
            raise click.BadParameter(str(exc))

        result = set_tags(
            args[0], parsed,
            api_key=api_key, base_url=base_url,
            encrypt=encrypt, encryption_key=encryption_key,
        )
        _audit("tag_set", {
            "attestation_id": args[0],
            "tag_count": len(parsed),
            "encrypted": encrypt,
        }, {"success": result.success, "upserted": result.tags_upserted})

        if json_output:
            click.echo(json.dumps(asdict(result), indent=2, default=str))
            if not result.success:
                sys.exit(1)
        else:
            if result.error:
                click.echo(click.style(f"Error: {result.error}", fg="red"), err=True)
                sys.exit(1)
            _echo_info(f"Set {result.tags_upserted} tag(s) on {args[0]}")

    elif action == "get":
        if not args:
            raise click.UsageError("Provide an attestation ID.")
        entries = get_tags(
            args[0],
            api_key=api_key, base_url=base_url,
            decrypt=decrypt, encryption_key=encryption_key,
        )
        if json_output:
            click.echo(json.dumps([asdict(e) for e in entries], indent=2, default=str))
        else:
            if not entries:
                click.echo("No tags found.")
                return
            for e in entries:
                enc = " [encrypted]" if e.client_encrypted else ""
                click.echo(f"  {e.key} = {e.value}{enc}")

    elif action == "delete":
        if len(args) < 2:
            raise click.UsageError("Provide <attestation_id> <tag_key>.")
        result = delete_tag(args[0], args[1], api_key=api_key, base_url=base_url)
        _audit("tag_delete", {"attestation_id": args[0], "key": args[1]}, {"success": result.success})
        if json_output:
            click.echo(json.dumps(asdict(result), indent=2, default=str))
            if not result.success:
                sys.exit(1)
        else:
            if result.error:
                click.echo(click.style(f"Error: {result.error}", fg="red"), err=True)
                sys.exit(1)
            _echo_info(f"Tag '{args[1]}' deleted from {args[0]}")

    elif action == "query":
        if not filters:
            raise click.UsageError("Provide at least one --filter key=value.")
        try:
            parsed_filters = [parse_tag_pair(f) for f in filters]
        except ValueError as exc:
            raise click.BadParameter(str(exc))

        result = query_tags(
            parsed_filters,
            api_key=api_key, base_url=base_url,
            limit=limit, cursor=cursor,
        )
        if json_output:
            click.echo(json.dumps(asdict(result), indent=2, default=str))
            if result.error:
                sys.exit(1)
        else:
            if result.error:
                click.echo(click.style(f"Error: {result.error}", fg="red"), err=True)
                sys.exit(1)
            if not result.attestation_ids:
                click.echo("No matching attestations found.")
                return
            click.echo(f"Found {result.count} attestation(s):")
            for aid in result.attestation_ids:
                click.echo(f"  {aid}")
            if result.next_cursor:
                click.echo(f"\nMore results available. Use --cursor {result.next_cursor}")


# ─── census key-rotate ─────────────────────────────────────────────────────

@main.command("key-rotate")
@click.argument("attestation_id")
@click.option("--old-key", required=True, help="Current AES-256 key (64 hex chars).")
@click.option("--new-key", required=True, help="New AES-256 key (64 hex chars).")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
@click.option("--json", "json_output", is_flag=True, help="Output as JSON.")
@click.pass_context
def key_rotate_cmd(
    ctx: click.Context,
    attestation_id: str,
    old_key: str,
    new_key: str,
    api_key: str | None,
    base_url: str | None,
    json_output: bool,
) -> None:
    """Rotate encryption key for an attestation's metadata and tags.

    Re-encrypts all client-encrypted data under the new key without
    exposing plaintext to the server (NIST SP 800-57 compliance).

    Examples:\b
        census key-rotate <att_id> --old-key <hex64> --new-key <hex64>
    """
    from .key_rotation import rotate_metadata_key, _validate_hex_key
    from dataclasses import asdict

    cfg = _get_config()
    api_key = api_key or cfg.get("api_key")
    base_url = base_url or cfg.get("base_url")

    try:
        _validate_hex_key(old_key, "old-key")
        _validate_hex_key(new_key, "new-key")
    except ValueError as exc:
        raise click.BadParameter(str(exc))

    result = rotate_metadata_key(
        attestation_id, old_key, new_key,
        api_key=api_key, base_url=base_url,
    )
    _audit("key_rotate", {
        "attestation_id": attestation_id,
    }, {
        "success": result.success,
        "rotated_metadata": result.rotated_metadata,
        "rotated_tags": result.rotated_tags,
    })

    if json_output:
        click.echo(json.dumps(asdict(result), indent=2, default=str))
        if not result.success:
            sys.exit(1)
    else:
        if result.error:
            click.echo(click.style(f"Error: {result.error}", fg="red"), err=True)
            sys.exit(1)
        parts = []
        if result.rotated_metadata:
            parts.append("metadata")
        if result.rotated_tags > 0:
            parts.append(f"{result.rotated_tags} tag(s)")
        if parts:
            _echo_info(f"Key rotated for {attestation_id}: {', '.join(parts)}")
        else:
            _echo_info(f"No encrypted data found on {attestation_id} — nothing to rotate.")


# ─── census derived-list ───────────────────────────────────────────────────

@main.command("derived-list")
@click.argument("action", type=click.Choice(["create", "list", "info", "match", "revoke", "access-log", "signature"]))
@click.argument("args", nargs=-1)
@click.option("--manifest", "manifest_path", default=None, type=click.Path(), help="Manifest to read hashes from.")
@click.option("--tag-filter", default=None, help="JSON tag filter (e.g. '{\"and\":[{\"key\":\"dept\",\"value\":\"legal\"}]}').")
@click.option("--label", default=None, help="Human-readable label for the list.")
@click.option("--expires", default=None, type=click.IntRange(min=1, max=2160), help="Expiry in hours (1–2160, i.e. max 90 days).")
@click.option("--list-key", default=None, help="HMAC list key (64 hex chars) for match.")
@click.option("--hashes-file", default=None, type=click.Path(exists=True), help="File with one hash per line for match.")
@click.option("--limit", default=50, type=int, help="Max access log entries. Default: 50.")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
@click.option("--json", "json_output", is_flag=True, help="Output as JSON.")
@click.pass_context
def derived_list_cmd(
    ctx: click.Context,
    action: str,
    args: tuple[str, ...],
    manifest_path: str | None,
    tag_filter: str | None,
    label: str | None,
    expires: int | None,
    list_key: str | None,
    hashes_file: str | None,
    limit: int,
    api_key: str | None,
    base_url: str | None,
    json_output: bool,
) -> None:
    """Census derived lists — opaque third-party hash verification.

    Create HMAC-SHA256 derived lists so third parties can check if their
    files match your inventory without seeing your actual hashes.

    Examples:\b
        census derived-list create --manifest ./manifest.db --label "Q1 2026"
        census derived-list create --tag-filter '{"and":[{"key":"dept","value":"legal"}]}'
        census derived-list list --json
        census derived-list info <list_id>
        census derived-list match <list_id> --list-key <hex64> --hashes-file suspects.txt
        census derived-list access-log <list_id>
        census derived-list signature <list_id>
        census derived-list revoke <list_id>
    """
    from .derived_lists import (
        create_derived_list, list_derived_lists,
        get_derived_list_detail, match_derived_list,
        revoke_derived_list, get_access_log, get_signature,
        DerivedListCreateResult,
    )
    from dataclasses import asdict

    label = _validate_label(label, "--label")
    cfg = _get_config()
    api_key = api_key or cfg.get("api_key")
    base_url = base_url or cfg.get("base_url")

    if action == "create":
        hashes = None
        tf = None
        if manifest_path:
            mpath = _resolve_manifest_path(Path(manifest_path))
            if not mpath.exists():
                raise click.BadParameter(f"Manifest not found: {manifest_path}")
            with _load_manifest(mpath) as m:
                hashes = [e.hash_hex for e in m.entries.values()]
            if not hashes:
                raise click.UsageError("Manifest is empty — no hashes to derive.")
        if tag_filter:
            try:
                tf = json.loads(tag_filter)
            except json.JSONDecodeError as exc:
                raise click.BadParameter(f"Invalid JSON in --tag-filter: {exc}")
            if not isinstance(tf, dict):
                raise click.BadParameter("--tag-filter must be a JSON object (e.g. {\"source\": \"label\"})")
        if not hashes and not tf:
            raise click.UsageError("Provide --manifest or --tag-filter.")

        result = create_derived_list(
            hashes=hashes, tag_filter=tf, label=label,
            expires_in_hours=expires,
            api_key=api_key, base_url=base_url,
        )
        _audit("derived_list_create", {
            "label": label,
            "hash_count": len(hashes) if hashes else 0,
        }, {"success": result.success, "list_id": result.list_id})

        if json_output:
            click.echo(json.dumps(asdict(result), indent=2, default=str))
            if not result.success:
                sys.exit(1)
        else:
            if result.error:
                click.echo(click.style(f"Error: {result.error}", fg="red"), err=True)
                sys.exit(1)
            _echo_info(f"Derived list created: {result.list_id}")
            click.echo(f"  List key:   {result.list_key}")
            click.echo(f"  Items:      {result.item_count}")
            click.echo(f"  Expires:    {result.expires_at}")
            if result.warning:
                click.echo(click.style(f"  Warning: {result.warning}", fg="yellow"), err=True)
            click.echo("\n  Share list_id + list_key with the third party.", err=True)
            click.echo("  The list_key is shown only once — save it now.", err=True)

    elif action == "list":
        items = list_derived_lists(api_key=api_key, base_url=base_url)
        if json_output:
            click.echo(json.dumps([asdict(i) for i in items], indent=2, default=str))
        else:
            if not items:
                click.echo("No derived lists found.")
                return
            for i in items:
                status = click.style("active", fg="green") if i.active else click.style("revoked", fg="red")
                click.echo(f"  {i.list_id}  {status}  {i.item_count} items  {i.created_at}")
                if i.label:
                    click.echo(f"    Label: {i.label}")

    elif action == "info":
        if not args:
            raise click.UsageError("Provide a list ID.")
        detail = get_derived_list_detail(args[0], api_key=api_key, base_url=base_url)
        if isinstance(detail, DerivedListCreateResult):
            if json_output:
                click.echo(json.dumps(asdict(detail), indent=2, default=str))
            else:
                click.echo(click.style(f"Error: {detail.error}", fg="red"), err=True)
            sys.exit(1)
        if json_output:
            click.echo(json.dumps(asdict(detail), indent=2, default=str))
        else:
            status = click.style("active", fg="green") if detail.active else click.style("revoked", fg="red")
            click.echo(f"List:       {detail.list_id}  [{status}]")
            click.echo(f"Items:      {detail.item_count}")
            click.echo(f"Matches:    {detail.match_count}")
            click.echo(f"Created:    {detail.created_at}")
            click.echo(f"Expires:    {detail.expires_at}")
            if detail.label:
                click.echo(f"Label:      {detail.label}")
            if detail.content_hash:
                click.echo(f"Hash:       {detail.content_hash[:16]}...")

    elif action == "match":
        if not args:
            raise click.UsageError("Provide a list ID.")
        if not list_key:
            raise click.UsageError("Provide --list-key for matching.")
        if not re.fullmatch(r"[0-9a-fA-F]{64}", list_key):
            raise click.BadParameter("--list-key must be 64 hex characters.")
        if not hashes_file:
            raise click.UsageError("Provide --hashes-file with one hash per line.")

        try:
            plain_hashes = Path(hashes_file).read_text(encoding="utf-8").strip().splitlines()
        except OSError as exc:
            raise click.ClickException(f"Cannot read hashes file: {exc}") from exc
        plain_hashes = [h.strip() for h in plain_hashes if h.strip()]
        if not plain_hashes:
            raise click.UsageError("Hashes file is empty.")

        result = match_derived_list(
            args[0], list_key, plain_hashes,
            api_key=api_key, base_url=base_url,
        )
        _audit("derived_list_match", {
            "list_id": args[0],
            "hash_count": len(plain_hashes),
        }, {"success": result.success, "matched": result.matched})

        if json_output:
            click.echo(json.dumps(asdict(result), indent=2, default=str))
            if not result.success:
                sys.exit(1)
        else:
            if result.error:
                click.echo(click.style(f"Error: {result.error}", fg="red"), err=True)
                sys.exit(1)
            click.echo(f"Match results for list {args[0]}:")
            click.echo(f"  Submitted: {result.total}")
            click.echo(f"  Matched:   {result.matched}")
            click.echo(f"  Unmatched: {result.unmatched}")
            if result.matched > 0:
                rate = result.matched / result.total * 100
                click.echo(click.style(
                    f"  Match rate: {rate:.1f}% — potential data exfiltration detected",
                    fg="red", bold=True,
                ), err=True)

    elif action == "revoke":
        if not args:
            raise click.UsageError("Provide a list ID to revoke.")
        result = revoke_derived_list(args[0], api_key=api_key, base_url=base_url)
        _audit("derived_list_revoke", {"list_id": args[0]}, {"success": result.success})
        if json_output:
            click.echo(json.dumps(asdict(result), indent=2, default=str))
            if not result.success:
                sys.exit(1)
        else:
            if result.error:
                click.echo(click.style(f"Error: {result.error}", fg="red"), err=True)
                sys.exit(1)
            _echo_info(f"Derived list {args[0]} revoked.")

    elif action == "access-log":
        if not args:
            raise click.UsageError("Provide a list ID.")
        entries = get_access_log(args[0], limit=limit, api_key=api_key, base_url=base_url)
        if json_output:
            click.echo(json.dumps([asdict(e) for e in entries], indent=2, default=str))
        else:
            if not entries:
                click.echo("No access log entries.")
                return
            for e in entries:
                click.echo(f"  {e.created_at}  matched={e.matched_count}/{e.total_submitted}  from {e.ip_address}")

    elif action == "signature":
        if not args:
            raise click.UsageError("Provide a list ID.")
        result = get_signature(args[0], base_url=base_url)
        if json_output:
            click.echo(json.dumps(asdict(result), indent=2, default=str))
            if not result.success:
                sys.exit(1)
        else:
            if result.error:
                click.echo(click.style(f"Error: {result.error}", fg="red"), err=True)
                sys.exit(1)
            click.echo(f"ECDSA signature for list {result.list_id}:")
            click.echo(f"  Signature:   {result.signature}")
            click.echo(f"  Signing key: {result.signing_key_id}")
            click.echo(f"  Content hash:{result.content_hash}")
            click.echo(f"  Items:       {result.item_count}")
            click.echo(f"  Created:     {result.created_at}")
            if result.expires_at:
                click.echo(f"  Expires:     {result.expires_at}")


# ─── census metadata ───────────────────────────────────────────────────────

@main.command("metadata")
@click.argument("action", type=click.Choice(["get"]))
@click.argument("attestation_id")
@click.option("--decrypt", is_flag=True, help="Decrypt encrypted extra_data.")
@click.option("--encryption-key", default=None, help="AES-256 key (64 hex chars).")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
@click.option("--json", "json_output", is_flag=True, help="Output as JSON.")
@click.pass_context
def metadata_cmd(
    ctx: click.Context,
    action: str,
    attestation_id: str,
    decrypt: bool,
    encryption_key: str | None,
    api_key: str | None,
    base_url: str | None,
    json_output: bool,
) -> None:
    """Read attestation metadata.

    Examples:\b
        census metadata get <att_id> --json
        census metadata get <att_id> --decrypt --encryption-key <hex64>
    """
    from certisigma import CertiSigmaError

    t0 = time.monotonic()
    client = _get_client(api_key, base_url)

    try:
        meta = client.get_metadata(attestation_id)
    except NotFoundError:
        msg = f"No metadata found for attestation {attestation_id}"
        if json_output:
            click.echo(json.dumps({"error": msg, "attestation_id": attestation_id}, indent=2, default=str))
        else:
            click.echo(click.style(f"Error: {msg}", fg="red"), err=True)
        sys.exit(1)
    except CertiSigmaError as exc:
        if json_output:
            click.echo(json.dumps({"error": str(exc)}, indent=2, default=str))
        else:
            click.echo(click.style(f"Error: {exc}", fg="red"), err=True)
        sys.exit(1)

    data = {
        "attestation_id": meta.attestation_id,
        "source": meta.source,
        "extra_data": meta.extra_data,
        "client_encrypted": meta.client_encrypted,
        "created_at": meta.created_at,
        "updated_at": meta.updated_at,
    }

    if decrypt and meta.client_encrypted and meta.extra_data:
        try:
            from .annotate import decrypt_annotation
            data["decrypted_extra_data"] = decrypt_annotation(
                meta.extra_data, encryption_key=encryption_key,
            )
        except Exception as exc:
            data["decrypt_error"] = str(exc)

    if json_output:
        click.echo(json.dumps(_with_meta(data, time.monotonic() - t0), indent=2, default=str))
    else:
        click.echo(f"Metadata for {attestation_id}:")
        if data.get("source"):
            click.echo(f"  Source: {data['source']}")
        click.echo(f"  Encrypted: {'yes' if meta.client_encrypted else 'no'}")
        click.echo(f"  Created:   {data.get('created_at', 'N/A')}")
        click.echo(f"  Updated:   {data.get('updated_at', 'N/A')}")
        if data.get("decrypted_extra_data"):
            click.echo(f"  Data: {json.dumps(data['decrypted_extra_data'], default=str)}")
        elif data.get("extra_data"):
            if meta.client_encrypted:
                click.echo("  Data: [encrypted — use --decrypt to view]")
            else:
                click.echo(f"  Data: {json.dumps(data['extra_data'], default=str)}")
        if data.get("decrypt_error"):
            click.echo(f"  Cannot decrypt: {data['decrypt_error']}", err=True)


# ─── census key-gen ────────────────────────────────────────────────────────

@main.command("key-gen")
@click.option("--json", "json_output", is_flag=True, help="Output as JSON.")
def key_gen_cmd(json_output: bool) -> None:
    """Generate a random AES-256 encryption key.

    The key is 64 hex characters (256 bits). Store it securely — it will
    only be shown once.

    Examples:\b
        census key-gen
        census key-gen --json
    """
    t0 = time.monotonic()
    try:
        from certisigma.crypto import generate_key
        key = generate_key()
    except Exception as exc:
        if json_output:
            click.echo(json.dumps({"error": str(exc)}, indent=2, default=str))
        else:
            click.echo(click.style(f"Error: {exc}", fg="red"), err=True)
        sys.exit(1)

    _audit("key_gen", {}, {})

    if json_output:
        click.echo(json.dumps(_with_meta({
            "key": key,
            "algorithm": "AES-256-GCM",
            "bits": 256,
        }, time.monotonic() - t0), indent=2, default=str))
    else:
        click.echo(click.style(
            "Store this key securely — it will not be shown again.",
            fg="yellow",
        ), err=True)
        click.echo(key)


# ─── census bulk-scan ──────────────────────────────────────────────────────

SCAN_CHUNK_SIZE = 50_000


@main.command("bulk-scan")
@click.argument("suspect_dir", type=click.Path(exists=True, file_okay=False, resolve_path=True))
@click.option("--manifest", "manifest_path", default=None, type=click.Path(), help="Local manifest for cross-referencing original paths.")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key (requires scan scope).")
@click.option("--base-url", default=None, help="API base URL override.")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@click.option("--include", "include_globs", multiple=True, help="Only include files matching this glob (repeatable).")
@click.option("--exclude", "exclude_globs", multiple=True, help="Exclude files matching this glob (repeatable).")
@click.option("--min-size", "min_size_str", default=None, help="Skip files smaller than this (e.g. 1K, 10M).")
@click.option("--max-size", "max_size_str", default=None, help="Skip files larger than this (e.g. 100M, 1G). Default: 5G.")
@click.option("--workers", default=1, type=int, help="Parallel hashing workers (1 = sequential). Default: 1.")
@click.option("--source", default=None, help="Source label for audit logging.")
@click.option("--dry-run", is_flag=True, help="Hash files and report count — no API call.")
@click.option("--output", "output_path", default=None, type=click.Path(), help="Save results to file (JSON).")
@click.option("--exit-zero", is_flag=True, help="Always exit 0 (report-only mode for CI pipelines).")
@click.option("--summary", is_flag=True, help="Show only summary counts, no match details.")
@click.option("--format", "output_format", type=click.Choice(["text", "json", "jsonl"]), default="text", help="Output format.")
@click.option("--on-match", "on_match_cmd", default=None, help="Run command with match results as JSON on stdin (only if matches > 0).")
@click.pass_context
def bulk_scan_cmd(
    ctx: click.Context,
    suspect_dir: str,
    manifest_path: str | None,
    api_key: str | None,
    base_url: str | None,
    json_output: bool,
    include_globs: tuple[str, ...],
    exclude_globs: tuple[str, ...],
    min_size_str: str | None,
    max_size_str: str | None,
    workers: int,
    source: str | None,
    dry_run: bool,
    output_path: str | None,
    exit_zero: bool,
    summary: bool,
    output_format: str,
    on_match_cmd: str | None,
) -> None:
    """Bulk leak detection: scan suspect files against org inventory.

    Uses the /scan endpoint (up to 50K hashes per call) to detect files
    that match your organization's attested inventory — potential
    exfiltration indicators.

    Requires an API key with 'scan' scope.

    \b
    Examples:
        census bulk-scan /mnt/suspect-drive
        census bulk-scan ./data --manifest inventory.db --json
        census bulk-scan /tmp/exports --workers 4 --source incident-001
        census bulk-scan /data --dry-run        # hash only, no API call
        census bulk-scan /data --exit-zero      # report only, never fail
    """
    from .retry import retry_api_call
    from .scanner import DEFAULT_MAX_FILE_SIZE, MAX_WORKERS, walk_files

    t0 = time.monotonic()

    if output_format != "text":
        json_output = output_format in ("json", "jsonl")
    elif json_output:
        output_format = "json"

    verbose = ctx.obj.get("verbose", False)
    quiet = ctx.obj.get("quiet", False)
    show_progress = not verbose and not json_output and not quiet

    source = _validate_label(source, "--source")
    min_size = _parse_size(min_size_str) if min_size_str else 0
    max_size = _parse_size(max_size_str) if max_size_str else DEFAULT_MAX_FILE_SIZE
    workers = max(1, min(workers, MAX_WORKERS))

    root = Path(suspect_dir)

    local_manifest = None
    if manifest_path:
        resolved_mp = _resolve_manifest_path(Path(manifest_path))
        if resolved_mp.exists():
            local_manifest = _load_manifest(resolved_mp)

    try:
        file_list = list(walk_files(
            root, include_globs=include_globs, exclude_globs=exclude_globs,
            min_file_size=min_size, max_file_size=max_size,
        ))

        if not json_output:
            _echo_info(f"Hashing {len(file_list)} files in {root}...")

        from certisigma import hash_file
        from concurrent.futures import ProcessPoolExecutor, as_completed

        hash_to_paths: dict[str, list[str]] = {}
        hash_errors = 0

        def _record(h: str, rel: str) -> None:
            hash_to_paths.setdefault(h, []).append(rel)

        if workers > 1 and len(file_list) > 1:
            bar_ctx = (
                click.progressbar(length=len(file_list), label="Hashing suspects", show_pos=True)
                if show_progress else None
            )
            with ProcessPoolExecutor(max_workers=workers) as pool:
                futures = {pool.submit(hash_file, str(fp)): fp for fp in file_list}
                if bar_ctx:
                    with bar_ctx as bar:
                        for future in as_completed(futures):
                            try:
                                h = future.result()
                                _record(h, str(futures[future].relative_to(root)))
                            except Exception as exc:
                                logger.warning("Cannot hash %s: %s", futures[future], exc)
                                hash_errors += 1
                            bar.update(1)
                else:
                    for future in as_completed(futures):
                        try:
                            h = future.result()
                            _record(h, str(futures[future].relative_to(root)))
                        except Exception as exc:
                            logger.warning("Cannot hash %s: %s", futures[future], exc)
                            hash_errors += 1
        else:
            ctx_mgr = (
                click.progressbar(file_list, label="Hashing suspects", show_pos=True)
                if show_progress else nullcontext(file_list)
            )
            with ctx_mgr as items:
                for fp in items:
                    try:
                        h = hash_file(str(fp))
                        _record(h, str(fp.relative_to(root)))
                    except Exception as exc:
                        logger.warning("Cannot hash %s: %s", fp, exc)
                        hash_errors += 1

        if hash_errors and not json_output:
            _echo_info(f"  ({hash_errors} file(s) skipped due to errors)")

        total_files = sum(len(ps) for ps in hash_to_paths.values())
        all_hashes = list(hash_to_paths.keys())
        if not all_hashes:
            if output_format == "jsonl":
                _emit_jsonl([], time.monotonic() - t0)
            elif json_output:
                click.echo(json.dumps(_with_meta(
                    {"total_scanned": 0, "matched": 0, "matches": []},
                    time.monotonic() - t0,
                ), indent=2, default=str))
            else:
                _echo_info("No files to scan.")
            return

        if dry_run:
            dry_base: dict = {
                "directory": str(root),
                "total_files": total_files,
                "unique_hashes": len(all_hashes),
                "chunks_needed": (len(all_hashes) + SCAN_CHUNK_SIZE - 1) // SCAN_CHUNK_SIZE,
            }
            if hash_errors:
                dry_base["hash_errors"] = hash_errors
            dry_data = _with_meta(dry_base, time.monotonic() - t0)
            if output_format == "jsonl":
                _emit_jsonl([dry_data], time.monotonic() - t0)
            elif json_output:
                click.echo(json.dumps(dry_data, indent=2, default=str))
            else:
                _echo_info(f"\nDry run summary:")
                _echo_info(f"  Files hashed:   {total_files}")
                _echo_info(f"  Unique hashes:  {len(all_hashes)}")
                _echo_info(f"  API calls needed: {dry_data['chunks_needed']}")
                if hash_errors:
                    _echo_info(f"  Hash errors:    {hash_errors}")
            _audit("bulk-scan-dry-run", {
                "directory": str(root), "total_files": total_files,
                "unique_hashes": len(all_hashes),
            }, {})
            return

        client = _get_client(api_key, base_url)
        if not json_output:
            _echo_info(f"Scanning {len(all_hashes)} unique hashes against org inventory...")

        all_matches: list[dict] = []
        matched_hashes: set[str] = set()
        scan_ids: list[str] = []
        for i in range(0, len(all_hashes), SCAN_CHUNK_SIZE):
            chunk = all_hashes[i:i + SCAN_CHUNK_SIZE]
            try:
                result = retry_api_call(client.scan, chunk)
                scan_ids.append(result.scan_id)
                for m in result.matches:
                    matched_hashes.add(m.hash_hex)
                    suspect_paths = hash_to_paths.get(m.hash_hex, [])
                    inv_path = None
                    if local_manifest:
                        entry = local_manifest.get(m.hash_hex)
                        if entry:
                            inv_path = entry.path
                    for sp in suspect_paths:
                        all_matches.append({
                            "hash_hex": m.hash_hex,
                            "suspect_path": sp,
                            "first_seen": m.first_seen,
                            "source": m.source,
                            "inventory_path": inv_path,
                        })
            except Exception as exc:
                logger.error("Scan chunk %d failed: %s", i // SCAN_CHUNK_SIZE + 1, exc)
                _audit("bulk-scan", {
                    "directory": str(root), "total_hashed": len(all_hashes),
                    "source": source,
                }, {
                    "error": str(exc),
                })
                if json_output:
                    click.echo(json.dumps({"error": str(exc)}, indent=2, default=str))
                else:
                    click.echo(click.style(f"Scan failed: {exc}", fg="red"), err=True)
                sys.exit(1)

        _audit("bulk-scan", {
            "directory": str(root), "total_hashed": len(all_hashes),
            "hash_errors": hash_errors, "source": source,
        }, {
            "matched": len(all_matches), "scan_ids": scan_ids,
        })

        unmatched_hashes = len(all_hashes) - len(matched_hashes)
        result_base: dict = {
            "directory": str(root),
            "total_files": total_files,
            "total_scanned": len(all_hashes),
            "matched_files": len(all_matches),
            "matched_hashes": len(matched_hashes),
            "unmatched_hashes": unmatched_hashes,
            "scan_ids": scan_ids,
            "matches": all_matches,
        }
        if source:
            result_base["source"] = source
        if hash_errors:
            result_base["hash_errors"] = hash_errors
        result_data = _with_meta(result_base, time.monotonic() - t0)

        if output_path and output_format == "jsonl":
            click.echo("Note: --output is ignored when --format jsonl is set.", err=True)
        elif output_path:
            try:
                Path(output_path).write_text(json.dumps(result_data, indent=2, default=str))
                if not json_output:
                    _echo_info(f"Results saved to {output_path}")
            except (OSError, TypeError, ValueError) as exc:
                if json_output:
                    click.echo(json.dumps({"error": f"Cannot write output: {exc}"}, indent=2, default=str))
                else:
                    click.echo(click.style(f"Cannot write output: {exc}", fg="red"), err=True)
                sys.exit(1)

        if output_format == "jsonl":
            _emit_jsonl(all_matches, time.monotonic() - t0, extra_summary={
                "total_files": total_files,
                "total_scanned": len(all_hashes),
                "matched_files": len(all_matches),
                "matched_hashes": len(matched_hashes),
                "unmatched_hashes": unmatched_hashes,
                "scan_ids": scan_ids,
            })
        elif json_output:
            click.echo(json.dumps(result_data, indent=2, default=str))
        else:
            _echo_info(f"\nScanned:   {len(all_hashes)} unique hashes ({total_files} files)")
            _echo_info(f"Matched:   {len(all_matches)} file(s) ({len(matched_hashes)} unique hash(es))")
            _echo_info(f"Unmatched: {unmatched_hashes} unique hash(es)")

            if not summary and all_matches:
                _echo_info(click.style(
                    f"\n{len(all_matches)} file(s) found in org inventory — potential leak:",
                    fg="red", bold=True,
                ))
                for m in all_matches:
                    inv = f" (inventory: {m['inventory_path']})" if m.get("inventory_path") else ""
                    _echo_info(f"  {m['suspect_path']}  first_seen: {m['first_seen']}{inv}")
            elif not all_matches:
                _echo_info(click.style("\nNo matches — files not in org inventory.", fg="green"))

            _echo_info(f"Completed in {time.monotonic() - t0:.1f}s")

        if on_match_cmd and all_matches:
            _run_on_match(on_match_cmd, _with_meta(
                {"matched": len(all_matches), "matches": all_matches},
                time.monotonic() - t0,
            ))

        if exit_zero:
            sys.exit(0)
        sys.exit(1 if all_matches else 0)

    finally:
        if local_manifest is not None:
            local_manifest.close()


# ─── census stats ──────────────────────────────────────────────────────────

@main.command("stats")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key (requires batch scope).")
@click.option("--base-url", default=None, help="API base URL override.")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@click.pass_context
def stats_cmd(ctx: click.Context, api_key: str | None, base_url: str | None, json_output: bool) -> None:
    """Show organization-level inventory statistics.

    Displays total claims, unique hashes, date range, and monthly
    breakdown for the organization associated with the API key.

    \b
    Examples:
        census stats
        census stats --json
    """
    from .retry import retry_api_call

    t0 = time.monotonic()
    client = _get_client(api_key, base_url)
    try:
        result = retry_api_call(client.get_bulk_stats)
    except Exception as exc:
        _audit("stats", {}, {"error": str(exc)})
        if json_output:
            click.echo(json.dumps({"error": str(exc)}, indent=2, default=str))
        else:
            click.echo(click.style(f"Error: {exc}", fg="red"), err=True)
        sys.exit(1)

    _audit("stats", {}, {
        "total_claims": result.total_claims,
        "unique_hashes": result.unique_hashes,
    })

    if json_output:
        from dataclasses import asdict
        click.echo(json.dumps(_with_meta(asdict(result), time.monotonic() - t0), indent=2, default=str))
    else:
        total = result.total_claims if result.total_claims is not None else 0
        unique = result.unique_hashes if result.unique_hashes is not None else 0
        _echo_info("Organization Inventory Statistics")
        _echo_info(f"  Total claims:  {total:,}")
        _echo_info(f"  Unique hashes: {unique:,}")
        if result.first_claim:
            _echo_info(f"  First claim:   {result.first_claim}")
        if result.last_claim:
            _echo_info(f"  Last claim:    {result.last_claim}")
        if result.claims_by_month:
            _echo_info("\n  Monthly breakdown:")
            for month, count in sorted(result.claims_by_month.items()):
                _echo_info(f"    {month}: {count:,}")


# ─── census seal / verify-seal ─────────────────────────────────────────────

@main.command("seal")
@click.argument("manifest_path", type=click.Path())
@click.option("--key", required=True, help="HMAC key (64 hex chars = 256 bits). Use 'census key-gen' to generate.")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
def seal_cmd(manifest_path: str, key: str, json_output: bool) -> None:
    """Create an HMAC-SHA256 seal for a manifest.

    The seal is a tamper-evidence sidecar file (.seal) that proves the
    manifest has not been modified since it was sealed. Use 'verify-seal'
    to check integrity before trusting a manifest.

    Examples:\b
        census seal ./manifest.db --key $(census key-gen)
        census seal ./manifest.db --key <hex64> --json
    """
    from .seal import create_seal

    t0 = time.monotonic()
    resolved = _resolve_manifest_path(Path(manifest_path))
    if not resolved.exists():
        raise click.BadParameter(f"Manifest not found: {manifest_path}")

    try:
        sp = create_seal(resolved, key)
    except (ValueError, OSError) as exc:
        if json_output:
            click.echo(json.dumps({"error": str(exc)}, indent=2, default=str))
        else:
            click.echo(click.style(f"Error: {exc}", fg="red"), err=True)
        sys.exit(1)

    _audit("seal", {"manifest": str(resolved)}, {"seal_path": str(sp)})

    if json_output:
        click.echo(json.dumps(_with_meta({
            "manifest": str(resolved),
            "seal_path": str(sp),
            "algorithm": "HMAC-SHA256",
        }, time.monotonic() - t0), indent=2, default=str))
    else:
        _echo_info(f"Seal created: {sp}")


@main.command("verify-seal")
@click.argument("manifest_path", type=click.Path())
@click.option("--key", required=True, help="HMAC key used to create the seal.")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
def verify_seal_cmd(manifest_path: str, key: str, json_output: bool) -> None:
    """Verify the HMAC-SHA256 seal of a manifest.

    Checks that the manifest has not been modified since it was sealed.
    Exit code 0 = valid, 1 = invalid or error.

    Examples:\b
        census verify-seal ./manifest.db --key <hex64>
        census verify-seal ./manifest.db --key <hex64> --json
    """
    from .seal import verify_seal

    t0 = time.monotonic()
    resolved = _resolve_manifest_path(Path(manifest_path))

    try:
        result = verify_seal(resolved, key)
    except (ValueError, OSError) as exc:
        if json_output:
            click.echo(json.dumps({"valid": False, "error": str(exc)}, indent=2, default=str))
        else:
            click.echo(click.style(f"Error: {exc}", fg="red"), err=True)
        sys.exit(1)

    _audit("verify_seal", {"manifest": str(resolved)}, {"valid": result.valid})

    if json_output:
        data = {
            "valid": result.valid,
            "manifest": result.manifest_path,
            "seal_path": result.seal_path,
        }
        if result.error:
            data["error"] = result.error
        click.echo(json.dumps(_with_meta(data, time.monotonic() - t0), indent=2, default=str))
    else:
        if result.valid:
            _echo_info(click.style("Seal valid — manifest integrity verified.", fg="green"))
        else:
            click.echo(click.style(f"Seal INVALID: {result.error}", fg="red", bold=True), err=True)

    if not result.valid:
        sys.exit(1)


# ─── census compliance-report ──────────────────────────────────────────────

@main.command("compliance-report")
@click.argument("manifest_path", type=click.Path(exists=True))
@click.option("-o", "--output", "output_path", default=None, type=click.Path(),
              help="Output file (.html or .json). Default: stdout.")
@click.option("--template", type=click.Choice(["nis2", "dora", "iso27001"]),
              default="nis2", help="Compliance framework. Default: nis2.")
@click.option("--integrity/--no-integrity", default=False,
              help="Run integrity check and include results.")
@click.option("--json", "json_output", is_flag=True,
              help="Machine-readable JSON output (implies no HTML).")
@click.pass_context
def compliance_report_cmd(
    ctx: click.Context,
    manifest_path: str,
    output_path: str | None,
    template: str,
    integrity: bool,
    json_output: bool,
) -> None:
    """Generate a compliance report (NIS2, DORA, ISO 27001).

    Maps Census data to regulatory requirements. 100% local — no API calls.

    Examples:\b
        census compliance-report manifest.db -o report.html
        census compliance-report manifest.db --template dora -o report.html
        census compliance-report manifest.db --integrity --json
    """
    from .compliance import (
        generate_compliance_report,
        render_compliance_html,
        render_compliance_json,
    )

    t0 = time.monotonic()

    manifest = _load_manifest(Path(manifest_path))
    try:
        integrity_report = None
        if integrity:
            from .evidence import integrity_check
            integrity_report = integrity_check(manifest)

        report = generate_compliance_report(
            manifest,
            template=template,
            integrity=integrity_report,
        )
        report.manifest_path = str(Path(manifest_path).resolve())

        _audit("compliance_report", {
            "manifest": report.manifest_path,
            "template": template,
        })

        elapsed = time.monotonic() - t0

        if json_output:
            data = render_compliance_json(report)
            click.echo(json.dumps(_with_meta(data, elapsed), indent=2, default=str))
        elif output_path:
            html_content = render_compliance_html(report)
            try:
                Path(output_path).write_text(html_content, encoding="utf-8")
            except (OSError, TypeError, ValueError) as exc:
                click.echo(click.style(f"Error writing report: {exc}", fg="red"), err=True)
                sys.exit(1)
            _echo_info(f"Compliance report saved: {output_path}")
        else:
            html_content = render_compliance_html(report)
            click.echo(html_content)
    finally:
        manifest.close()


# ─── census watch ──────────────────────────────────────────────────────────

@main.command()
@click.argument("directory", type=click.Path(exists=True, file_okay=False, resolve_path=True))
@click.option("--debounce", default=2.0, type=float, help="Quiet period before processing (seconds). Default: 2.0")
@click.option("--batch-interval", default=30.0, type=float, help="Max seconds between attestation batches. Default: 30")
@click.option("--scan-on-start/--no-scan-on-start", default=True, help="Full baseline scan before watching. Default: on")
@click.option("--on-delete", type=click.Choice(["ignore", "mark", "remove"]), default="ignore", help="Action on file deletion. Default: ignore")
@click.option("--polling", is_flag=True, help="Use polling observer (for NFS/CIFS mounts).")
@click.option("--poll-interval", default=5.0, type=float, help="Polling interval in seconds (only with --polling). Default: 5")
@click.option("--source", default=None, help="Source label for attestations.")
@click.option("--manifest", "manifest_path", default=None, type=click.Path(), help="Path to manifest. Default: <directory>/.census-manifest.db")
@click.option("--api-key", default=None, envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", default=None, help="API base URL override.")
@click.option("--dry-run", is_flag=True, help="Hash and index only, do not attest.")
@click.option("--include", "include_globs", multiple=True, help="Only include files matching this glob (repeatable).")
@click.option("--exclude", "exclude_globs", multiple=True, help="Exclude files matching this glob (repeatable).")
@click.option("--min-size", "min_size_str", default=None, help="Skip files smaller than this (e.g. 1K, 10M).")
@click.option("--max-size", "max_size_str", default=None, help="Skip files larger than this (e.g. 100M, 1G). Default: 5G.")
@click.pass_context
def watch(
    ctx: click.Context,
    directory: str,
    debounce: float,
    batch_interval: float,
    scan_on_start: bool,
    on_delete: str,
    polling: bool,
    poll_interval: float,
    source: str | None,
    manifest_path: str | None,
    api_key: str | None,
    base_url: str | None,
    dry_run: bool,
    include_globs: tuple[str, ...],
    exclude_globs: tuple[str, ...],
    min_size_str: str | None,
    max_size_str: str | None,
) -> None:
    """Watch a directory for changes and attest new/modified files.

    Monitors the filesystem using native OS events (inotify on Linux,
    FSEvents on macOS) and processes changes in batches with configurable
    debouncing.

    Requires: pip install certisigma-census[watch]
    """
    from .scanner import DEFAULT_MAX_FILE_SIZE
    from .watcher import watch_directory

    source = _validate_label(source, "--source")
    verbose = ctx.obj.get("verbose", False)
    quiet = ctx.obj.get("quiet", False)
    show_progress = not verbose and not quiet

    min_size = _parse_size(min_size_str) if min_size_str else 0
    max_size = _parse_size(max_size_str) if max_size_str else DEFAULT_MAX_FILE_SIZE

    root = Path(directory)
    mpath = Path(manifest_path) if manifest_path else root / ".census-manifest.db"
    resolved_mpath = _resolve_manifest_path(mpath)
    if resolved_mpath.exists():
        mpath = resolved_mpath

    client = None
    if not dry_run:
        client = _get_client(api_key, base_url)

    _audit("watch_start", {"directory": str(root), "dry_run": dry_run})
    try:
        watch_directory(
            root,
            manifest_path=mpath,
            client=client,
            source=source,
            debounce_sec=debounce,
            batch_interval=batch_interval,
            scan_on_start=scan_on_start,
            on_delete=on_delete,
            polling=polling,
            poll_interval=poll_interval,
            dry_run=dry_run,
            include_globs=include_globs,
            exclude_globs=exclude_globs,
            min_file_size=min_size,
            max_file_size=max_size,
            show_progress=show_progress,
            quiet=quiet,
        )
    finally:
        _audit("watch_stop", {"directory": str(root)})


# ── AI Governance ────────────────────────────────────────────────────


@main.group("ai-policy")
@click.pass_context
def ai_policy_group(ctx):
    """AI governance: classify assets for ML/AI training compliance."""


@ai_policy_group.command("init")
@click.option("--output", "-o", type=click.Path(), default=".census-ai-policy.toml",
              help="Output path for the policy template.")
@click.option("--json", "json_output", is_flag=True, hidden=True)
@click.pass_context
def ai_policy_init(ctx, output, json_output):
    """Generate a default AI policy template."""
    from .ai_policy import generate_template_policy

    t0 = time.monotonic()
    out = Path(output)
    try:
        fd = os.open(str(out), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(generate_template_policy())
    except FileExistsError:
        click.echo(click.style(f"File already exists: {out}", fg="red"), err=True)
        sys.exit(1)
    except OSError as exc:
        click.echo(click.style(f"Cannot write policy: {exc}", fg="red"), err=True)
        sys.exit(1)
    _audit("ai_policy_init", {}, {"path": str(out)})
    if json_output:
        click.echo(json.dumps(_with_meta({"path": str(out)}, time.monotonic() - t0),
                              indent=2, default=str))
    else:
        _echo_info(f"Policy template written to {out}")


@ai_policy_group.command("apply")
@click.argument("manifest_path", type=click.Path(exists=True))
@click.option("--policy", "-p", type=click.Path(exists=True),
              default=".census-ai-policy.toml",
              help="Path to the TOML policy file.")
@click.option("--dry-run", is_flag=True, help="Classify only, do not tag.")
@click.option("--api-key", envvar="CERTISIGMA_API_KEY", help="CertiSigma API key.")
@click.option("--base-url", help="API base URL override.")
@click.option("--json", "json_output", is_flag=True, help="JSON output.")
@click.option("--quiet", "-q", "quiet_flag", is_flag=True, hidden=True)
@click.pass_context
def ai_policy_apply(ctx, manifest_path, policy, dry_run, api_key, base_url,
                    json_output, quiet_flag):
    """Classify manifest entries and tag attestations."""
    from .ai_policy import (
        classify_manifest,
        load_policy,
        render_ai_report_json,
        tag_classifications,
    )

    t0 = time.monotonic()
    resolved = Path(manifest_path).resolve()

    try:
        pol = load_policy(Path(policy).resolve())
    except (ValueError, OSError) as exc:
        click.echo(click.style(f"Policy error: {exc}", fg="red"), err=True)
        sys.exit(1)

    with _load_manifest(resolved) as manifest:
        report = classify_manifest(manifest, pol)

        if not dry_run:
            if not api_key:
                click.echo(click.style(
                    "API key required for tagging (use --api-key or CERTISIGMA_API_KEY)",
                    fg="red",
                ), err=True)
                sys.exit(1)
            tagged, errors = tag_classifications(
                report, api_key=api_key, base_url=base_url,
            )
        else:
            tagged, errors = 0, 0

        _audit("ai_policy_apply", {
            "manifest": str(resolved),
            "policy": policy,
            "dry_run": dry_run,
        }, {
            "total": report.total_files,
            "allowed": report.allowed,
            "excluded": report.excluded,
            "tagged": tagged,
            "errors": errors,
        })

        if json_output:
            data = render_ai_report_json(report)
            click.echo(json.dumps(_with_meta(data, time.monotonic() - t0),
                                  indent=2, default=str))
        else:
            _echo_info(f"Policy:     {report.policy_name} v{report.policy_version}")
            _echo_info(f"Framework:  {report.framework or 'custom'}")
            _echo_info(f"Total:      {report.total_files}")
            _echo_info(f"Allowed:    {report.allowed}")
            _echo_info(f"Excluded:   {report.excluded}")
            _echo_info(f"By rule:    {report.classified_by_rule}")
            _echo_info(f"By default: {report.classified_by_default}")
            if not dry_run:
                _echo_info(f"Tagged:     {tagged}")
                if errors:
                    click.echo(click.style(f"Tag errors: {errors}", fg="red"), err=True)
            else:
                _echo_info("(dry run — no tags applied)")

        if errors:
            sys.exit(1)


@ai_policy_group.command("report")
@click.argument("manifest_path", type=click.Path(exists=True))
@click.option("--policy", "-p", type=click.Path(exists=True),
              default=".census-ai-policy.toml",
              help="Path to the TOML policy file.")
@click.option("--output", "-o", type=click.Path(),
              help="Save report to file (HTML or JSON based on extension).")
@click.option("--json", "json_output", is_flag=True, help="JSON output to stdout.")
@click.option("--quiet", "-q", "quiet_flag", is_flag=True, hidden=True)
@click.pass_context
def ai_policy_report(ctx, manifest_path, policy, output, json_output, quiet_flag):
    """Generate an AI governance compliance report."""
    from .ai_policy import (
        classify_manifest,
        load_policy,
        render_ai_report_html,
        render_ai_report_json,
    )

    quiet = ctx.obj.get("quiet", False) if ctx.obj else quiet_flag
    t0 = time.monotonic()
    resolved = Path(manifest_path).resolve()

    try:
        pol = load_policy(Path(policy).resolve())
    except (ValueError, OSError) as exc:
        click.echo(click.style(f"Policy error: {exc}", fg="red"), err=True)
        sys.exit(1)

    with _load_manifest(resolved) as manifest:
        report = classify_manifest(manifest, pol)

    _audit("ai_policy_report", {
        "manifest": str(resolved),
        "policy": policy,
    }, {
        "total": report.total_files,
        "allowed": report.allowed,
        "excluded": report.excluded,
    })

    if output:
        out = Path(output)
        try:
            if out.suffix == ".json":
                data = _with_meta(render_ai_report_json(report), time.monotonic() - t0)
                out.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
            else:
                html_content = render_ai_report_html(report)
                out.write_text(html_content, encoding="utf-8")
            if not quiet:
                _echo_info(f"Report saved to {out}")
        except (OSError, TypeError, ValueError) as exc:
            click.echo(click.style(f"Cannot write report: {exc}", fg="red"), err=True)
            sys.exit(1)
    elif json_output:
        data = render_ai_report_json(report)
        click.echo(json.dumps(_with_meta(data, time.monotonic() - t0),
                              indent=2, default=str))
    else:
        _echo_info(f"Policy:     {report.policy_name} v{report.policy_version}")
        _echo_info(f"Framework:  {report.framework or 'custom'}")
        _echo_info(f"Total:      {report.total_files}")
        _echo_info(f"Allowed:    {report.allowed}")
        _echo_info(f"Excluded:   {report.excluded}")
        _echo_info(f"Coverage:   {report.classified_by_rule}/{report.total_files} by rule")


if __name__ == "__main__":
    main()
