"""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
from contextlib import nullcontext
from pathlib import Path

import click

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])


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)


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):
    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
    return CertiSigmaClient(**kwargs)


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 _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


@click.group()
@click.version_option(__version__, prog_name="certisigma-census")
@click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.")
@click.option("--log-format", "log_format", type=click.Choice(["text", "json"]), default="text", help="Log output format (default: text).")
@click.pass_context
def main(ctx: click.Context, verbose: bool, log_format: str) -> None:
    """CertiSigma Census — Cryptographic file inventory & breach detection."""
    _setup_logging(verbose, log_format=log_format)
    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose


@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("--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,
    json_output: bool,
) -> None:
    """Scan a directory, compute hashes, and attest them via CertiSigma."""
    from .manifest import Manifest
    from .scanner import DEFAULT_MAX_FILE_SIZE, scan_directory

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

    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"

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

    if not json_output:
        click.echo(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,
    )

    if existing_manifest:
        existing_manifest.merge(manifest)
        manifest = existing_manifest

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

    if dry_run:
        manifest.save(mpath)
        if json_output:
            click.echo(json.dumps({
                "directory": str(root), "manifest": str(mpath),
                "total": manifest.total, "attested": 0, "dry_run": True,
            }, indent=2))
        else:
            click.echo(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)
            manifest.save(mpath)

    if json_output:
        click.echo(json.dumps({
            "directory": str(root), "manifest": str(mpath),
            "total": manifest.total, "attested": attested, "dry_run": False,
        }, indent=2))
    else:
        click.echo(f"Attested {attested}/{manifest.total} files. Manifest: {mpath}")


@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("--json", "json_output", is_flag=True, help="Machine-readable JSON output.")
@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,
    json_output: bool,
) -> None:
    """Compare suspect files against the CertiSigma registry."""
    from .compare import compare_directory
    from .manifest import Manifest
    from .scanner import DEFAULT_MAX_FILE_SIZE

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

    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 = Manifest.load(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,
        )

        if json_output:
            from dataclasses import asdict
            click.echo(json.dumps(asdict(report), indent=2, default=str))
        else:
            click.echo(f"\nSuspect files:  {report.total_suspect}")
            click.echo(f"Matched:        {report.matched}")
            click.echo(f"Not matched:    {report.not_matched}")
            click.echo(f"Errors:         {report.errors}")

            if report.matches:
                click.echo("\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 "?"
                    click.echo(f"  {m.suspect_path} → att_{att_id} [{level}]{inv}")

            if output_path:
                _write_compare_report(report, Path(output_path))
                click.echo(f"\nReport saved to {output_path}")

        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", "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 "",
                    "timestamp": m.timestamp or "",
                    "inventory_path": m.inventory_path or "",
                })
    else:
        data = asdict(report)
        path.write_text(json.dumps(data, indent=2), 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)."""
    from .manifest import Manifest

    resolved = _resolve_manifest_path(Path(manifest_path))
    if not resolved.exists():
        raise click.BadParameter(f"Manifest not found: {manifest_path}")
    with Manifest.load(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(data, indent=2))
    else:
        click.echo(f"Manifest: {data['manifest']}")
        click.echo(f"Root:     {data['root']}")
        click.echo(f"Total:    {data['total']}")
        click.echo(f"Attested: {data['attested']}")
        click.echo(f"Pending:  {data['pending']}")


@main.command(name="export")
@click.argument("manifest_path", type=click.Path())
@click.option("--format", "fmt", type=click.Choice(["csv", "json"]), 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 or JSON."""
    from .manifest import Manifest

    resolved = _resolve_manifest_path(Path(manifest_path))
    if not resolved.exists():
        raise click.BadParameter(f"Manifest not found: {manifest_path}")
    with Manifest.load(resolved) as manifest:
        if fmt == "csv":
            _export_manifest_csv(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_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)
    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

    if is_file:
        fpath = Path(hash_or_file)
        if not fpath.exists():
            raise click.BadParameter(f"File not found: {hash_or_file}")
        hash_hex = sdk_hash_file(str(fpath))
        if not json_output:
            click.echo(f"File:        {fpath}")
            click.echo(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)
    detail = verify_hash(client, hash_hex)

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

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

    if not detail.exists:
        return

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

    if detail.t0 or detail.t1 or detail.t2:
        click.echo("\nEvidence chain:")
        if detail.t0:
            algo = detail.t0.get("algorithm", "ECDSA-P256-SHA256")
            sig = detail.t0.get("signature", "")[:20]
            click.echo(f"  T0  {algo}  sig: {sig}...")
        if detail.t1:
            tsa = detail.t1.get("tsaProvider", "TSA")
            ts = detail.t1.get("timestamp", "")
            click.echo(f"  T1  {tsa}  {ts}")
        if detail.t2:
            block = detail.t2.get("bitcoinBlock", "")
            click.echo(f"  T2  Bitcoin block {block}")
            if detail.blockchain_url:
                click.echo(f"      Block:  {detail.blockchain_url}")
            if detail.blockchain_tx_url:
                click.echo(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:
                click.echo(f"\nOTS proof saved to {ots_path}")
            else:
                click.echo("\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}")


@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("--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.pass_context
def integrity_cmd(
    ctx: click.Context,
    manifest_path: str,
    json_output: bool,
    output_path: str | None,
    strict: bool,
) -> 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
    Examples:
        census integrity manifest.db
        census integrity manifest.db --strict
        census integrity manifest.db --json
        census integrity manifest.db --output results.csv
    """
    from .evidence import integrity_check
    from .manifest import Manifest

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

    resolved = _resolve_manifest_path(Path(manifest_path))
    if not resolved.exists():
        raise click.BadParameter(f"Manifest not found: {manifest_path}")
    with Manifest.load(resolved) as manifest:
        report = integrity_check(manifest, show_progress=show_progress)

    if json_output:
        from dataclasses import asdict
        click.echo(json.dumps(asdict(report), indent=2, default=str))
    else:
        click.echo(f"Integrity check: {report.root_dir} ({report.total_in_manifest} files in manifest)")
        click.echo("")
        click.echo(f"  Unchanged:  {report.unchanged}")
        click.echo(f"  Modified:   {len(report.modified)}")
        click.echo(f"  Missing:    {len(report.missing)}")
        click.echo(f"  New:        {len(report.new_files)}")

        if report.modified:
            click.echo("\nModified files:")
            for m in report.modified:
                click.echo(
                    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:
            click.echo("\nMissing files:")
            for p in report.missing:
                click.echo(f"  {p}")

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

        if report.errors:
            click.echo("\nErrors:")
            for e in report.errors:
                click.echo(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")
        else:
            click.echo("\nResult: INTEGRITY OK — all files match baseline")

    if output_path:
        _write_integrity_report(report, Path(output_path))
        if not json_output:
            click.echo(f"Results saved to {output_path}")

    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")


@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 .manifest import Manifest
    from .report import (
        generate_evidence_bundle,
        generate_html_report,
        generate_pdf_report,
    )

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

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

    with Manifest.load(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)
            click.echo(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)
                click.echo(f"Integrity check: {total} discrepancies found")
            else:
                click.echo("Integrity check: all files match baseline")

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

        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)
            click.echo(f"Evidence bundle saved to {output_path}")

        elif ext == ".pdf":
            try:
                content = generate_pdf_report(
                    manifest, evidence=evidence_data, integrity=integrity_report,
                )
                out.write_bytes(content)
                click.echo(f"PDF report saved to {output_path}")
            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")
            click.echo(f"HTML 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("--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_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
    from .manifest import Manifest

    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 Manifest.load(base_path) as base, Manifest.load(target_path) 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:
        click.echo(f"Base:     {result.base_root}")
        click.echo(f"Target:   {result.target_root}")
        click.echo("")
        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}")

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

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

            if result.modified:
                click.echo("\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
                    click.echo(
                        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:
            click.echo(f"\nResult: {len(result.added) + len(result.removed) + len(result.modified)} differences found")
        else:
            click.echo("\nResult: manifests are identical")

    if output_path:
        _write_diff_report(result, Path(output_path))
        if not json_output:
            click.echo(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", type=click.Path(exists=True, resolve_path=True))
@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,
    expected_hash: str | None,
    json_output: bool,
) -> None:
    """Compute SHA-256 hash of a file or all files in a directory.

    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
    """
    from certisigma import hash_file as sdk_hash_file

    target_path = Path(target)

    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)}."
            )
        if not target_path.is_file():
            raise click.UsageError("--verify requires a single file, not a directory.")
    else:
        expected = None

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

    if json_output:
        click.echo(json.dumps(results, indent=2))
        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]}...)")
            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=int, help="Seconds between polls (default: 60).")
@click.option("--timeout", default=3600, type=int, 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
    """
    import time

    from .retry import retry_api_call

    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))
            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(data, indent=2, default=str))
        else:
            click.echo(f"ID:         {status.id}")
            click.echo(f"Hash:       {status.hash_hex}")
            click.echo(f"Level:      {status.level}")
            click.echo(f"T0:         {status.t0_timestamp}")
            t1 = status.t1_timestamp or "pending"
            click.echo(f"T1 (TSA):   {t1}")
            t2 = status.t2_timestamp or "pending"
            click.echo(f"T2 (BTC):   {t2}")
            if status.t2_bitcoin_block:
                click.echo(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}")
            sys.exit(2)

        if not json_output:
            click.echo(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"

        if path.exists():
            click.echo(f"Config already exists: {path}")
            return

        init_config(path)
        click.echo(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

    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

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

    merged.save(out)
    total_entries = merged.total
    merged.close()

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

    if json_output:
        click.echo(json.dumps(summary, indent=2))
    else:
        click.echo(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 STATUS_FAIL, run_doctor

    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(asdict(report), 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")
            click.echo(click.style(f"  {sym} ", fg=color) + check.message)
            if check.detail:
                click.echo(f"    {check.detail}")

        click.echo("")
        if report.all_ok:
            click.echo(click.style("All checks passed.", fg="green"))
        else:
            click.echo(
                f"{report.ok_count} ok, {report.warn_count} warnings, "
                f"{report.fail_count} failures"
            )

    if not report.all_ok:
        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()
@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

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

    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)

    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,
    )


if __name__ == "__main__":
    main()
