"""CLI integration tests — exercises Click commands with mocked SDK.

Uses click.testing.CliRunner so no real API calls are made.
"""

from __future__ import annotations

import csv
import json
from io import StringIO
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from click.testing import CliRunner

from certisigma_census.cli import _parse_size, main
from certisigma_census.manifest import Manifest, ManifestEntry


DETERMINISTIC_HASH = "ab" * 32


def _deterministic_hash(path_str: str) -> str:
    """Return a hash derived from the filename for reproducibility."""
    name = Path(path_str).name
    h = f"{abs(hash(name)) % (16**64):064x}"
    return h


def _mock_batch_attest_response(hashes, source=None):
    """Build a BatchAttestResult mock from a list of hashes."""
    attestations = [
        {
            "id": f"att_{i}",
            "hash_hex": h,
            "timestamp": "2026-03-01T12:00:00Z",
            "status": "created",
        }
        for i, h in enumerate(hashes)
    ]
    return MagicMock(
        batch_id="batch_test",
        count=len(hashes),
        created=len(hashes),
        existing=0,
        attestations=attestations,
    )


def _mock_batch_verify_response(hashes, all_exist=False):
    """Build a BatchVerifyResult mock."""
    results = [
        {"hash_hex": h, "exists": all_exist, "attestation_id": f"att_{h[:8]}", "level": "T1"}
        for h in hashes
    ]
    return MagicMock(count=len(hashes), found=len(hashes) if all_exist else 0,
                     not_found=0 if all_exist else len(hashes), results=results)


class TestVersion:
    def test_cli_version(self):
        runner = CliRunner()
        result = runner.invoke(main, ["--version"])
        assert result.exit_code == 0
        assert "certisigma-census" in result.output
        assert "0.6.0" in result.output


class TestLogging:
    def test_default_text_logging(self, tmp_path):
        """Default log format is text (no JSON)."""
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=100, mtime=1.0))
        mp = tmp_path / "m.json"
        m.save(mp)

        runner = CliRunner()
        result = runner.invoke(main, ["-v", "status", str(mp)])
        assert result.exit_code == 0
        assert "Total:" in result.output

    def test_json_log_format(self, tmp_path):
        """--log-format json is accepted and doesn't crash."""
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=100, mtime=1.0))
        mp = tmp_path / "m.json"
        m.save(mp)

        runner = CliRunner()
        result = runner.invoke(main, [
            "-v", "--log-format", "json", "status", str(mp),
        ])
        assert result.exit_code == 0
        assert "Total:" in result.output

    def test_json_formatter_output(self):
        """_JsonLogFormatter produces valid JSON."""
        import logging as _logging
        from certisigma_census.cli import _JsonLogFormatter

        formatter = _JsonLogFormatter()
        record = _logging.LogRecord(
            name="certisigma_census.scanner",
            level=_logging.WARNING,
            pathname="scanner.py",
            lineno=42,
            msg="Cannot read %s: %s",
            args=("/tmp/secret.bin", "Permission denied"),
            exc_info=None,
        )
        output = formatter.format(record)
        parsed = json.loads(output)
        assert parsed["level"] == "WARNING"
        assert parsed["module"] == "scanner"
        assert "Permission denied" in parsed["message"]


class TestScan:
    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_scan_dry_run(self, _mock_hash, populated_dir):
        """Dry run hashes files but makes zero API calls."""
        runner = CliRunner()
        manifest_path = str(populated_dir / "test-manifest.json")

        result = runner.invoke(main, [
            "scan", str(populated_dir),
            "--dry-run",
            "--manifest", manifest_path,
        ])

        assert result.exit_code == 0
        assert "Dry run" in result.output
        db_path = Path(manifest_path).with_suffix(".db")
        assert db_path.exists()

        manifest = Manifest.load(Path(manifest_path))
        assert manifest.total == 5
        assert manifest.attested_count == 0

    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_scan_with_attestation(self, mock_get_client, _mock_hash, populated_dir):
        """Scan with attestation calls batch_attest and writes manifest."""
        client = MagicMock()
        client.batch_attest.side_effect = _mock_batch_attest_response
        mock_get_client.return_value = client

        manifest_path = str(populated_dir / "test-manifest.json")
        runner = CliRunner()
        result = runner.invoke(main, [
            "scan", str(populated_dir),
            "--source", "test-inventory",
            "--manifest", manifest_path,
        ])

        assert result.exit_code == 0
        assert "Attested" in result.output
        client.batch_attest.assert_called_once()

        manifest = Manifest.load(Path(manifest_path))
        assert manifest.total == 5

    def test_scan_nonexistent_dir(self):
        """Scanning a non-existent directory exits with error."""
        runner = CliRunner()
        result = runner.invoke(main, ["scan", "/nonexistent/path/xyz"])
        assert result.exit_code != 0


class TestCompare:
    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_with_matches(self, mock_get_client, _mock_hash, populated_dir):
        """Compare exits 1 when matches are found."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes: _mock_batch_verify_response(hashes, all_exist=True)
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["compare", str(populated_dir)])

        assert result.exit_code == 1
        assert "Matched:" in result.output

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_no_matches(self, mock_get_client, _mock_hash, populated_dir):
        """Compare exits 0 when no matches found."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes: _mock_batch_verify_response(hashes, all_exist=False)
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["compare", str(populated_dir)])

        assert result.exit_code == 0
        assert "Matched:        0" in result.output

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_json_output(self, mock_get_client, _mock_hash, populated_dir):
        """Compare writes a valid JSON report when --output is given."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes: _mock_batch_verify_response(hashes, all_exist=True)
        mock_get_client.return_value = client

        output_path = str(populated_dir / "report.json")
        runner = CliRunner()
        result = runner.invoke(main, [
            "compare", str(populated_dir),
            "--output", output_path,
        ])

        assert Path(output_path).exists()
        report = json.loads(Path(output_path).read_text())
        assert report["total_suspect"] == 5
        assert report["matched"] == 5


class TestStatus:
    def test_status_output(self, tmp_path):
        """Status command displays correct counts."""
        m = Manifest(root_dir="/data/hr")
        for i in range(10):
            m.add(ManifestEntry(hash_hex=f"{i:064x}", path=f"f_{i}.dat", size=100, mtime=1.0))
        for i in range(4):
            m.mark_attested(f"{i:064x}", f"att_{i}", "2026-01-01T00:00:00Z")

        manifest_path = tmp_path / "manifest.json"
        m.save(manifest_path)

        runner = CliRunner()
        result = runner.invoke(main, ["status", str(manifest_path)])

        assert result.exit_code == 0
        assert "Total:    10" in result.output
        assert "Attested: 4" in result.output
        assert "Pending:  6" in result.output


class TestCompareCsvOutput:
    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_csv_output(self, mock_get_client, _mock_hash, populated_dir):
        """Compare writes valid CSV when --output ends with .csv."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes: _mock_batch_verify_response(hashes, all_exist=True)
        mock_get_client.return_value = client

        output_path = str(populated_dir / "report.csv")
        runner = CliRunner()
        result = runner.invoke(main, [
            "compare", str(populated_dir),
            "--output", output_path,
        ])

        assert Path(output_path).exists()
        with open(output_path, newline="", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            rows = list(reader)
        assert len(rows) == 5
        assert "suspect_path" in reader.fieldnames
        assert "hash_hex" in reader.fieldnames
        assert all(row["matched"] == "true" for row in rows)


class TestExport:
    def test_export_manifest_csv(self, tmp_path):
        """census export produces valid CSV output."""
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        m.add(ManifestEntry(hash_hex="bb" * 32, path="b.txt", size=200, mtime=2.0))
        m.mark_attested("aa" * 32, "att_1", "2026-01-01T00:00:00Z")

        manifest_path = tmp_path / "m.json"
        m.save(manifest_path)
        output_path = str(tmp_path / "export.csv")

        runner = CliRunner()
        result = runner.invoke(main, [
            "export", str(manifest_path),
            "--format", "csv",
            "--output", output_path,
        ])

        assert result.exit_code == 0
        with open(output_path, newline="", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            rows = list(reader)
        assert len(rows) == 2
        assert "hash_hex" in reader.fieldnames
        assert "attested" in reader.fieldnames

    def test_export_manifest_json(self, tmp_path):
        """census export --format json produces valid JSON."""
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        manifest_path = tmp_path / "m.json"
        m.save(manifest_path)
        output_path = str(tmp_path / "export.json")

        runner = CliRunner()
        result = runner.invoke(main, [
            "export", str(manifest_path),
            "--format", "json",
            "--output", output_path,
        ])

        assert result.exit_code == 0
        data = json.loads(Path(output_path).read_text())
        assert data["total"] == 1
        assert len(data["entries"]) == 1

    def test_export_csv_stdout(self, tmp_path):
        """census export without --output writes CSV to stdout."""
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="cc" * 32, path="c.txt", size=300, mtime=3.0))
        manifest_path = tmp_path / "m.json"
        m.save(manifest_path)

        runner = CliRunner()
        result = runner.invoke(main, [
            "export", str(manifest_path),
            "--format", "csv",
        ])

        assert result.exit_code == 0
        assert "hash_hex" in result.output
        assert "cc" * 32 in result.output


class TestParseSize:
    def test_bytes_plain(self):
        assert _parse_size("1024") == 1024

    def test_kilobytes(self):
        assert _parse_size("10K") == 10 * 1024
        assert _parse_size("10KB") == 10 * 1024

    def test_megabytes(self):
        assert _parse_size("5M") == 5 * 1024 ** 2
        assert _parse_size("5MB") == 5 * 1024 ** 2

    def test_gigabytes(self):
        assert _parse_size("2G") == 2 * 1024 ** 3

    def test_fractional(self):
        assert _parse_size("1.5G") == int(1.5 * 1024 ** 3)

    def test_case_insensitive(self):
        assert _parse_size("10m") == 10 * 1024 ** 2

    def test_invalid_raises(self):
        import click
        with pytest.raises(click.exceptions.BadParameter):
            _parse_size("abc")

    def test_unknown_unit_raises(self):
        import click
        with pytest.raises(click.exceptions.BadParameter):
            _parse_size("10X")


class TestScanFilters:
    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_scan_with_include_filter(self, _mock_hash, populated_dir):
        """--include filters to only matching files."""
        runner = CliRunner()
        manifest_path = str(populated_dir / "filtered.json")

        result = runner.invoke(main, [
            "scan", str(populated_dir),
            "--dry-run",
            "--manifest", manifest_path,
            "--include", "*.pdf",
            "--include", "*.csv",
        ])

        assert result.exit_code == 0
        manifest = Manifest.load(Path(manifest_path))
        assert manifest.total == 2
        paths = sorted(e.path for e in manifest.entries.values())
        assert paths == ["data.csv", "report.pdf"]

    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_scan_with_exclude_filter(self, _mock_hash, populated_dir):
        """--exclude removes matching files from scan."""
        runner = CliRunner()
        manifest_path = str(populated_dir / "filtered.json")

        result = runner.invoke(main, [
            "scan", str(populated_dir),
            "--dry-run",
            "--manifest", manifest_path,
            "--exclude", "*.png",
        ])

        assert result.exit_code == 0
        manifest = Manifest.load(Path(manifest_path))
        assert manifest.total == 4
        paths = sorted(e.path for e in manifest.entries.values())
        assert "image.png" not in paths

    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_scan_with_size_filters(self, _mock_hash, tmp_path):
        """--min-size and --max-size filter by file size."""
        (tmp_path / "tiny.txt").write_bytes(b"x")
        (tmp_path / "medium.txt").write_bytes(b"x" * 500)
        (tmp_path / "huge.txt").write_bytes(b"x" * 5000)

        runner = CliRunner()
        manifest_path = str(tmp_path / "sized.json")

        result = runner.invoke(main, [
            "scan", str(tmp_path),
            "--dry-run",
            "--manifest", manifest_path,
            "--min-size", "100",
            "--max-size", "2K",
        ])

        assert result.exit_code == 0
        manifest = Manifest.load(Path(manifest_path))
        assert manifest.total == 1
        assert list(manifest.entries.values())[0].path == "medium.txt"


class TestResolveManifestPath:
    def test_json_path_resolves_to_existing_db(self, tmp_path):
        """_resolve_manifest_path('.json') finds the .db sibling when it exists."""
        from certisigma_census.cli import _resolve_manifest_path

        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=100, mtime=1.0))
        m.save(tmp_path / "test.json")
        assert (tmp_path / "test.db").exists()

        resolved = _resolve_manifest_path(tmp_path / "test.json")
        assert resolved == tmp_path / "test.db"

    def test_db_path_returns_directly(self, tmp_path):
        """_resolve_manifest_path('.db') returns the .db path when it exists."""
        from certisigma_census.cli import _resolve_manifest_path

        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=100, mtime=1.0))
        m.save(tmp_path / "test.db")

        resolved = _resolve_manifest_path(tmp_path / "test.db")
        assert resolved == tmp_path / "test.db"

    def test_missing_path_returns_original(self, tmp_path):
        """_resolve_manifest_path returns the original path when nothing exists."""
        from certisigma_census.cli import _resolve_manifest_path

        original = tmp_path / "nonexistent.db"
        resolved = _resolve_manifest_path(original)
        assert resolved == original

    def test_db_falls_back_to_json_sibling(self, tmp_path):
        """_resolve_manifest_path('.db') finds .json sibling for legacy manifests."""
        from certisigma_census.cli import _resolve_manifest_path

        (tmp_path / "legacy.json").write_text('{"entries":{}}')
        resolved = _resolve_manifest_path(tmp_path / "legacy.db")
        assert resolved == tmp_path / "legacy.json"


class TestVerify:
    @patch("certisigma_census.evidence.retry_api_call")
    def test_verify_attested_hash(self, mock_retry, mock_client):
        """census verify <hash> shows evidence chain for attested hash."""
        mock_retry.side_effect = lambda fn, *a, **kw: fn(*a, **kw)
        mock_client.verify.return_value = MagicMock(
            exists=True, hash_hex="ab" * 32, id="att_99",
            level="T1", timestamp="2026-01-01T00:00:00Z", source="test",
        )
        mock_client.get_evidence.return_value = MagicMock(
            hash_hex="ab" * 32, level="T1",
            t0={"algorithm": "ECDSA"}, t1={"tsaProvider": "QuoVadis"}, t2=None,
            id="att_99",
        )

        runner = CliRunner()
        with patch("certisigma_census.cli._get_client", return_value=mock_client):
            result = runner.invoke(main, ["verify", "ab" * 32])

        assert result.exit_code == 0
        assert "Attested" in result.output
        assert "att_99" in result.output

    @patch("certisigma_census.evidence.retry_api_call")
    def test_verify_unattested_hash(self, mock_retry, mock_client):
        """census verify <hash> for an unknown hash shows 'Not found'."""
        mock_retry.side_effect = lambda fn, *a, **kw: fn(*a, **kw)
        mock_client.verify.return_value = MagicMock(
            exists=False, hash_hex="cc" * 32, id=None,
            level=None, timestamp=None, source=None,
        )

        runner = CliRunner()
        with patch("certisigma_census.cli._get_client", return_value=mock_client):
            result = runner.invoke(main, ["verify", "cc" * 32])

        assert result.exit_code == 0
        assert "Not found" in result.output

    @patch("certisigma_census.evidence.retry_api_call")
    def test_verify_json_output(self, mock_retry, mock_client):
        """census verify --json produces valid JSON."""
        mock_retry.side_effect = lambda fn, *a, **kw: fn(*a, **kw)
        mock_client.verify.return_value = MagicMock(
            exists=True, hash_hex="ab" * 32, id="att_99",
            level="T0", timestamp="2026-01-01", source=None,
        )
        mock_client.get_evidence.return_value = MagicMock(
            hash_hex="ab" * 32, level="T0",
            t0={"algorithm": "ECDSA"}, t1=None, t2=None, id="att_99",
        )

        runner = CliRunner()
        with patch("certisigma_census.cli._get_client", return_value=mock_client):
            result = runner.invoke(main, ["verify", "ab" * 32, "--json"])

        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["exists"] is True
        assert data["hash_hex"] == "ab" * 32

    @patch("certisigma_census.evidence.retry_api_call")
    @patch("certisigma.hash_file", return_value="ab" * 32)
    def test_verify_file_mode(self, _mock_hash, mock_retry, mock_client, tmp_path):
        """census verify --file hashes a file then verifies."""
        mock_retry.side_effect = lambda fn, *a, **kw: fn(*a, **kw)
        mock_client.verify.return_value = MagicMock(
            exists=False, hash_hex="ab" * 32, id=None,
            level=None, timestamp=None, source=None,
        )

        test_file = tmp_path / "sample.pdf"
        test_file.write_bytes(b"test pdf content")

        runner = CliRunner()
        with patch("certisigma_census.cli._get_client", return_value=mock_client):
            result = runner.invoke(main, ["verify", str(test_file), "--file"])

        assert result.exit_code == 0
        assert "SHA-256:" in result.output

    def test_verify_help(self):
        runner = CliRunner()
        result = runner.invoke(main, ["verify", "--help"])
        assert result.exit_code == 0
        assert "--file" in result.output
        assert "--save-ots" in result.output
        assert "--json" in result.output

    def test_verify_rejects_invalid_hash(self):
        """census verify rejects non-hex or wrong-length input."""
        runner = CliRunner()
        result = runner.invoke(main, ["verify", "not-a-hash"])
        assert result.exit_code != 0
        assert "Invalid SHA-256 hash" in result.output

    def test_verify_rejects_short_hash(self):
        runner = CliRunner()
        result = runner.invoke(main, ["verify", "abcd1234"])
        assert result.exit_code != 0
        assert "64 hex characters" in result.output


class TestIntegrity:
    @patch("certisigma_census.evidence.hash_file")
    def test_integrity_all_ok(self, mock_hash, tmp_path):
        """census integrity with no changes shows INTEGRITY OK."""
        root = tmp_path / "data"
        root.mkdir()
        (root / "a.txt").write_bytes(b"hello")
        mock_hash.return_value = "aa" * 32

        m = Manifest(root_dir=str(root))
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=5, mtime=1.0))
        mp = tmp_path / "m.db"
        m.save(mp)

        runner = CliRunner()
        result = runner.invoke(main, ["integrity", str(mp)])

        assert result.exit_code == 0
        assert "INTEGRITY OK" in result.output

    @patch("certisigma_census.evidence.hash_file")
    def test_integrity_detects_modification(self, mock_hash, tmp_path):
        """census integrity detects tampered file."""
        root = tmp_path / "data"
        root.mkdir()
        (root / "a.txt").write_bytes(b"modified")
        mock_hash.return_value = "bb" * 32

        m = Manifest(root_dir=str(root))
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=5, mtime=1.0))
        mp = tmp_path / "m.db"
        m.save(mp)

        runner = CliRunner()
        result = runner.invoke(main, ["integrity", str(mp)])

        assert result.exit_code == 0
        assert "INTEGRITY VIOLATION" in result.output
        assert "Modified:" in result.output

    @patch("certisigma_census.evidence.hash_file")
    def test_integrity_strict_exits_1(self, mock_hash, tmp_path):
        """census integrity --strict exits 1 on discrepancies."""
        root = tmp_path / "data"
        root.mkdir()
        (root / "a.txt").write_bytes(b"modified")
        mock_hash.return_value = "bb" * 32

        m = Manifest(root_dir=str(root))
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=5, mtime=1.0))
        mp = tmp_path / "m.db"
        m.save(mp)

        runner = CliRunner()
        result = runner.invoke(main, ["integrity", str(mp), "--strict"])

        assert result.exit_code == 1

    @patch("certisigma_census.evidence.hash_file")
    def test_integrity_json_output(self, mock_hash, tmp_path):
        """census integrity --json produces valid JSON."""
        root = tmp_path / "data"
        root.mkdir()
        (root / "a.txt").write_bytes(b"hello")
        mock_hash.return_value = "aa" * 32

        m = Manifest(root_dir=str(root))
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=5, mtime=1.0))
        mp = tmp_path / "m.db"
        m.save(mp)

        runner = CliRunner()
        result = runner.invoke(main, ["integrity", str(mp), "--json"])

        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["unchanged"] == 1

    @patch("certisigma_census.evidence.hash_file")
    def test_integrity_json_with_output_no_stdout_contamination(self, mock_hash, tmp_path):
        """--json + --output: 'Results saved' must not appear on stdout."""
        root = tmp_path / "data"
        root.mkdir()
        (root / "a.txt").write_bytes(b"hello")
        mock_hash.return_value = "aa" * 32

        m = Manifest(root_dir=str(root))
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=5, mtime=1.0))
        mp = tmp_path / "m.db"
        m.save(mp)

        out_file = tmp_path / "results.json"
        runner = CliRunner()
        result = runner.invoke(main, ["integrity", str(mp), "--json", "--output", str(out_file)])

        assert result.exit_code == 0
        data = json.loads(result.output)
        assert "unchanged" in data
        assert "Results saved" not in result.output

    def test_integrity_help(self):
        runner = CliRunner()
        result = runner.invoke(main, ["integrity", "--help"])
        assert result.exit_code == 0
        assert "--strict" in result.output
        assert "--json" in result.output


class TestReport:
    def test_report_help(self):
        runner = CliRunner()
        result = runner.invoke(main, ["report", "--help"])
        assert result.exit_code == 0
        assert "--output" in result.output
        assert "--evidence" in result.output
        assert "--integrity" in result.output
        assert "--bundle" in result.output

    def test_report_html_output(self, tmp_path):
        """census report generates valid HTML."""
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        mp = tmp_path / "m.db"
        m.save(mp)

        output = tmp_path / "report.html"
        runner = CliRunner()
        result = runner.invoke(main, ["report", str(mp), "-o", str(output)])

        assert result.exit_code == 0
        assert output.exists()
        content = output.read_text()
        assert "<!DOCTYPE html>" in content
        assert "a.txt" in content

    def test_report_pdf_output(self, tmp_path):
        """census report generates valid PDF."""
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        mp = tmp_path / "m.db"
        m.save(mp)

        output = tmp_path / "report.pdf"
        runner = CliRunner()
        result = runner.invoke(main, ["report", str(mp), "-o", str(output)])

        assert result.exit_code == 0
        assert output.exists()
        assert output.read_bytes()[:5] == b"%PDF-"

    def test_report_in_help_listing(self):
        runner = CliRunner()
        result = runner.invoke(main, ["--help"])
        assert "report" in result.output


class TestDiffCommand:
    def test_diff_identical_manifests(self, tmp_path):
        """census diff exits 0 for identical manifests."""
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        mp1 = tmp_path / "base.db"
        mp2 = tmp_path / "target.db"
        m.save(mp1)
        m.save(mp2)

        runner = CliRunner()
        result = runner.invoke(main, ["diff", str(mp1), str(mp2)])
        assert result.exit_code == 0
        assert "manifests are identical" in result.output

    def test_diff_detects_changes(self, tmp_path):
        """census diff detects added/removed/modified files."""
        base = Manifest(root_dir="/data")
        base.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        base.add(ManifestEntry(hash_hex="bb" * 32, path="b.txt", size=200, mtime=1.0))
        mp1 = tmp_path / "base.db"
        base.save(mp1)

        target = Manifest(root_dir="/data")
        target.add(ManifestEntry(hash_hex="cc" * 32, path="a.txt", size=150, mtime=2.0))
        target.add(ManifestEntry(hash_hex="dd" * 32, path="c.txt", size=300, mtime=2.0))
        mp2 = tmp_path / "target.db"
        target.save(mp2)

        runner = CliRunner()
        result = runner.invoke(main, ["diff", str(mp1), str(mp2)])
        assert result.exit_code == 7  # added + removed + modified
        assert "Added:      1" in result.output
        assert "Removed:    1" in result.output
        assert "Modified:   1" in result.output

    def test_diff_json_output(self, tmp_path):
        """census diff --json produces valid JSON."""
        base = Manifest(root_dir="/data")
        base.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        mp1 = tmp_path / "base.db"
        base.save(mp1)

        target = Manifest(root_dir="/data")
        mp2 = tmp_path / "target.db"
        target.save(mp2)

        runner = CliRunner()
        result = runner.invoke(main, ["diff", str(mp1), str(mp2), "--json"])
        data = json.loads(result.output)
        assert len(data["removed"]) == 1
        assert data["unchanged_count"] == 0

    def test_diff_summary_mode(self, tmp_path):
        """census diff --summary shows only counts, no file details."""
        base = Manifest(root_dir="/data")
        base.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        mp1 = tmp_path / "base.db"
        base.save(mp1)
        target = Manifest(root_dir="/data")
        mp2 = tmp_path / "target.db"
        target.save(mp2)

        runner = CliRunner()
        result = runner.invoke(main, ["diff", str(mp1), str(mp2), "--summary"])
        assert "Removed:    1" in result.output
        assert "- a.txt" not in result.output

    def test_diff_html_output(self, tmp_path):
        """census diff -o diff.html writes valid HTML."""
        base = Manifest(root_dir="/data")
        base.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        mp1 = tmp_path / "base.db"
        base.save(mp1)
        target = Manifest(root_dir="/data")
        target.add(ManifestEntry(hash_hex="bb" * 32, path="b.txt", size=200, mtime=1.0))
        mp2 = tmp_path / "target.db"
        target.save(mp2)

        html_out = tmp_path / "diff.html"
        runner = CliRunner()
        result = runner.invoke(main, ["diff", str(mp1), str(mp2), "-o", str(html_out)])
        assert html_out.exists()
        content = html_out.read_text()
        assert "<!DOCTYPE html>" in content
        assert "b.txt" in content

    def test_diff_csv_output(self, tmp_path):
        """census diff -o diff.csv writes valid CSV."""
        base = Manifest(root_dir="/data")
        base.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        mp1 = tmp_path / "base.db"
        base.save(mp1)
        target = Manifest(root_dir="/data")
        mp2 = tmp_path / "target.db"
        target.save(mp2)

        csv_out = tmp_path / "diff.csv"
        runner = CliRunner()
        result = runner.invoke(main, ["diff", str(mp1), str(mp2), "-o", str(csv_out)])
        assert csv_out.exists()
        content = csv_out.read_text()
        assert "status" in content
        assert "removed" in content

    def test_diff_json_with_output_no_stdout_contamination(self, tmp_path):
        """--json + --output: 'Report saved' must not appear on stdout."""
        base = Manifest(root_dir="/data")
        base.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        mp1 = tmp_path / "base.db"
        base.save(mp1)
        target = Manifest(root_dir="/data")
        mp2 = tmp_path / "target.db"
        target.save(mp2)

        out_file = tmp_path / "diff.json"
        runner = CliRunner()
        result = runner.invoke(main, ["diff", str(mp1), str(mp2), "--json", "-o", str(out_file)])
        data = json.loads(result.output)
        assert "removed" in data
        assert "Report saved" not in result.output
        assert out_file.exists()

    def test_diff_missing_manifest(self, tmp_path):
        """census diff with missing manifest exits with error."""
        mp1 = tmp_path / "exists.db"
        Manifest(root_dir="/data").save(mp1)
        runner = CliRunner()
        result = runner.invoke(main, ["diff", str(mp1), str(tmp_path / "missing.db")])
        assert result.exit_code != 0

    def test_diff_help(self):
        runner = CliRunner()
        result = runner.invoke(main, ["diff", "--help"])
        assert result.exit_code == 0
        assert "--json" in result.output
        assert "--summary" in result.output


class TestHashCommand:
    @patch("certisigma.hash_file", return_value="ab" * 32)
    def test_hash_single_file(self, _mock_hash, tmp_path):
        """census hash <file> outputs SHA-256."""
        f = tmp_path / "test.txt"
        f.write_bytes(b"hello")
        runner = CliRunner()
        result = runner.invoke(main, ["hash", str(f)])
        assert result.exit_code == 0
        assert ("ab" * 32) in result.output

    @patch("certisigma.hash_file", return_value="ab" * 32)
    def test_hash_verify_ok(self, _mock_hash, tmp_path):
        """census hash --verify <correct> exits 0."""
        f = tmp_path / "test.txt"
        f.write_bytes(b"hello")
        runner = CliRunner()
        result = runner.invoke(main, ["hash", str(f), "--verify", "ab" * 32])
        assert result.exit_code == 0
        assert "OK" in result.output

    @patch("certisigma.hash_file", return_value="ab" * 32)
    def test_hash_verify_mismatch(self, _mock_hash, tmp_path):
        """census hash --verify <wrong> exits 1."""
        f = tmp_path / "test.txt"
        f.write_bytes(b"hello")
        runner = CliRunner()
        result = runner.invoke(main, ["hash", str(f), "--verify", "cc" * 32])
        assert result.exit_code == 1
        assert "MISMATCH" in result.output

    @patch("certisigma.hash_file", return_value="ab" * 32)
    def test_hash_verify_invalid_hash(self, _mock_hash, tmp_path):
        """census hash --verify <invalid> raises error."""
        f = tmp_path / "test.txt"
        f.write_bytes(b"hello")
        runner = CliRunner()
        result = runner.invoke(main, ["hash", str(f), "--verify", "not-hex"])
        assert result.exit_code != 0

    @patch("certisigma.hash_file", return_value="ab" * 32)
    def test_hash_verify_on_directory_rejected(self, _mock_hash, tmp_path):
        """census hash --verify on a directory must fail."""
        (tmp_path / "a.txt").write_bytes(b"aaa")
        runner = CliRunner()
        result = runner.invoke(main, ["hash", str(tmp_path), "--verify", "ab" * 32])
        assert result.exit_code != 0
        assert "single file" in result.output.lower() or "directory" in result.output.lower()

    @patch("certisigma.hash_file", return_value="ab" * 32)
    def test_hash_directory(self, _mock_hash, tmp_path):
        """census hash <dir> hashes all visible files."""
        (tmp_path / "a.txt").write_bytes(b"aaa")
        (tmp_path / "b.txt").write_bytes(b"bbb")
        runner = CliRunner()
        result = runner.invoke(main, ["hash", str(tmp_path)])
        assert result.exit_code == 0
        assert result.output.count("ab" * 32) == 2

    @patch("certisigma.hash_file", return_value="ab" * 32)
    def test_hash_json_output(self, _mock_hash, tmp_path):
        """census hash --json produces valid JSON array."""
        f = tmp_path / "test.txt"
        f.write_bytes(b"hello")
        runner = CliRunner()
        result = runner.invoke(main, ["hash", str(f), "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert isinstance(data, list)
        assert len(data) == 1
        assert data[0]["hash"] == "ab" * 32

    def test_hash_help(self):
        runner = CliRunner()
        result = runner.invoke(main, ["hash", "--help"])
        assert result.exit_code == 0
        assert "--verify" in result.output
        assert "--json" in result.output


class TestTrackCommand:
    @patch("certisigma_census.cli._get_client")
    @patch("certisigma_census.retry.time.sleep")
    def test_track_shows_status(self, _sleep, mock_get_client):
        """census track displays attestation status."""
        client = MagicMock()
        client.status.return_value = MagicMock(
            id="att_123", hash_hex="ab" * 32, level="T1",
            t0_timestamp="2026-01-01", t1_timestamp="2026-01-02",
            t2_timestamp=None, t2_bitcoin_block=None,
            signature_available=True, tsa_available=True,
            merkle_proof_available=False,
        )
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["track", "att_123"])
        assert result.exit_code == 0
        assert "att_123" in result.output
        assert "T1" in result.output

    @patch("certisigma_census.cli._get_client")
    @patch("certisigma_census.retry.time.sleep")
    def test_track_json_output(self, _sleep, mock_get_client):
        """census track --json produces valid JSON."""
        client = MagicMock()
        client.status.return_value = MagicMock(
            id="att_123", hash_hex="ab" * 32, level="T2",
            t0_timestamp="2026-01-01", t1_timestamp="2026-01-02",
            t2_timestamp="2026-01-10", t2_bitcoin_block=876543,
            signature_available=True, tsa_available=True,
            merkle_proof_available=True,
        )
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["track", "att_123", "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["level"] == "T2"
        assert data["t2_bitcoin_block"] == 876543

    @patch("certisigma_census.cli._get_client")
    @patch("certisigma_census.retry.time.sleep")
    def test_track_poll_stops_at_t2(self, mock_sleep, mock_get_client):
        """census track --poll stops when T2 is reached."""
        client = MagicMock()
        responses = [
            MagicMock(
                id="att_123", hash_hex="ab" * 32, level="T1",
                t0_timestamp="2026-01-01", t1_timestamp="2026-01-02",
                t2_timestamp=None, t2_bitcoin_block=None,
                signature_available=True, tsa_available=True,
                merkle_proof_available=False,
            ),
            MagicMock(
                id="att_123", hash_hex="ab" * 32, level="T2",
                t0_timestamp="2026-01-01", t1_timestamp="2026-01-02",
                t2_timestamp="2026-01-10", t2_bitcoin_block=876543,
                signature_available=True, tsa_available=True,
                merkle_proof_available=True,
            ),
        ]
        client.status.side_effect = responses
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["track", "att_123", "--poll", "--poll-interval", "1"])
        assert result.exit_code == 0
        assert client.status.call_count == 2

    @patch("certisigma_census.cli._get_client")
    @patch("certisigma_census.retry.time.sleep")
    def test_track_api_error(self, _sleep, mock_get_client):
        """census track exits 1 on API error."""
        client = MagicMock()
        client.status.side_effect = Exception("connection refused")
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["track", "att_123"])
        assert result.exit_code == 1

    def test_track_help(self):
        runner = CliRunner()
        result = runner.invoke(main, ["track", "--help"])
        assert result.exit_code == 0
        assert "--poll" in result.output
        assert "--timeout" in result.output


class TestConfigCommand:
    def test_config_show(self):
        """census config show displays merged config."""
        runner = CliRunner()
        result = runner.invoke(main, ["config", "show"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert "api_key" in data
        assert "base_url" in data

    def test_config_init_project(self, tmp_path):
        """census config init --project creates .census.toml."""
        runner = CliRunner()
        with patch("certisigma_census.cli.Path.cwd", return_value=tmp_path):
            result = runner.invoke(main, ["config", "init", "--project"])
        assert result.exit_code == 0
        config_file = tmp_path / ".census.toml"
        assert config_file.exists()
        assert "CertiSigma Census" in config_file.read_text()

    def test_config_init_existing_skips(self, tmp_path):
        """census config init with existing file does not overwrite."""
        config_file = tmp_path / ".census.toml"
        config_file.write_text("existing content")
        runner = CliRunner()
        with patch("certisigma_census.cli.Path.cwd", return_value=tmp_path):
            result = runner.invoke(main, ["config", "init", "--project"])
        assert "already exists" in result.output
        assert config_file.read_text() == "existing content"

    def test_config_paths(self):
        """census config paths shows config locations."""
        runner = CliRunner()
        result = runner.invoke(main, ["config", "paths"])
        assert result.exit_code == 0
        assert "user" in result.output

    def test_config_help(self):
        runner = CliRunner()
        result = runner.invoke(main, ["config", "--help"])
        assert result.exit_code == 0
        assert "show" in result.output
        assert "init" in result.output


class TestCompletionCommand:
    def test_completion_bash(self):
        """census completion bash outputs bash completion script."""
        runner = CliRunner()
        result = runner.invoke(main, ["completion", "bash"])
        assert result.exit_code == 0
        assert "_CENSUS_COMPLETE" in result.output or "census" in result.output

    def test_completion_zsh(self):
        runner = CliRunner()
        result = runner.invoke(main, ["completion", "zsh"])
        assert result.exit_code == 0

    def test_completion_fish(self):
        runner = CliRunner()
        result = runner.invoke(main, ["completion", "fish"])
        assert result.exit_code == 0

    def test_completion_help(self):
        runner = CliRunner()
        result = runner.invoke(main, ["completion", "--help"])
        assert result.exit_code == 0
        assert "bash" in result.output
        assert "zsh" in result.output


class TestWatch:
    def test_watch_help(self):
        runner = CliRunner()
        result = runner.invoke(main, ["watch", "--help"])
        assert result.exit_code == 0
        assert "--debounce" in result.output
        assert "--scan-on-start" in result.output
        assert "--on-delete" in result.output
        assert "--polling" in result.output

    def test_watch_in_help_listing(self):
        runner = CliRunner()
        result = runner.invoke(main, ["--help"])
        assert "watch" in result.output


class TestScanResume:
    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_scan_resume_flag(self, _mock_hash, tmp_path):
        """--resume loads existing manifest and skips unchanged files."""
        scan_dir = tmp_path / "scan_root"
        scan_dir.mkdir()
        for name in ("a.pdf", "b.csv", "c.txt"):
            (scan_dir / name).write_bytes(f"content-{name}".encode())

        runner = CliRunner()
        manifest_path = str(tmp_path / "resume.json")

        result1 = runner.invoke(main, [
            "scan", str(scan_dir),
            "--dry-run",
            "--manifest", manifest_path,
        ])
        assert result1.exit_code == 0
        m1 = Manifest.load(Path(manifest_path))
        assert m1.total == 3

        (scan_dir / "new_file.txt").write_bytes(b"added after first scan")

        result2 = runner.invoke(main, [
            "scan", str(scan_dir),
            "--dry-run",
            "--resume",
            "--manifest", manifest_path,
        ])
        assert result2.exit_code == 0
        assert "Resuming" in result2.output
        m2 = Manifest.load(Path(manifest_path))
        assert m2.total == 4

    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_scan_resume_no_manifest_proceeds_normally(self, _mock_hash, tmp_path):
        """--resume with no existing manifest behaves like a fresh scan."""
        scan_dir = tmp_path / "scan_root"
        scan_dir.mkdir()
        for name in ("a.pdf", "b.csv"):
            (scan_dir / name).write_bytes(f"content-{name}".encode())

        runner = CliRunner()
        manifest_path = str(tmp_path / "nonexistent.json")

        result = runner.invoke(main, [
            "scan", str(scan_dir),
            "--dry-run",
            "--resume",
            "--manifest", manifest_path,
        ])
        assert result.exit_code == 0
        assert "Resuming" not in result.output
        m = Manifest.load(Path(manifest_path))  # resolver finds .db
        assert m.total == 2


class TestDoctorCommand:
    @patch("certisigma_census.doctor.check_api_connectivity")
    @patch("certisigma_census.doctor.check_api_key")
    def test_doctor_runs(self, mock_key, mock_conn):
        from certisigma_census.doctor import CheckResult, STATUS_OK
        mock_conn.return_value = CheckResult("api_health", STATUS_OK, "API reachable")
        mock_key.return_value = CheckResult("api_key", STATUS_OK, "Key valid")
        runner = CliRunner()
        result = runner.invoke(main, ["doctor"])
        assert result.exit_code == 0
        assert "passed" in result.output.lower() or "ok" in result.output.lower()

    @patch("certisigma_census.doctor.check_api_connectivity")
    @patch("certisigma_census.doctor.check_api_key")
    def test_doctor_json_output(self, mock_key, mock_conn):
        from certisigma_census.doctor import CheckResult, STATUS_OK
        mock_conn.return_value = CheckResult("api_health", STATUS_OK, "API reachable")
        mock_key.return_value = CheckResult("api_key", STATUS_OK, "Key valid")
        runner = CliRunner()
        result = runner.invoke(main, ["doctor", "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert "checks" in data

    @patch("certisigma_census.doctor.check_api_connectivity")
    @patch("certisigma_census.doctor.check_api_key")
    def test_doctor_with_manifest(self, mock_key, mock_conn, tmp_path):
        from certisigma_census.doctor import CheckResult, STATUS_OK
        mock_conn.return_value = CheckResult("api_health", STATUS_OK, "ok")
        mock_key.return_value = CheckResult("api_key", STATUS_OK, "ok")
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        mp = tmp_path / "test.db"
        m.save(mp)
        m.close()

        runner = CliRunner()
        result = runner.invoke(main, ["doctor", "--manifest", str(mp)])
        assert result.exit_code == 0

    @patch("certisigma_census.doctor.check_api_connectivity")
    @patch("certisigma_census.doctor.check_api_key")
    def test_doctor_exit_1_on_failure(self, mock_key, mock_conn):
        from certisigma_census.doctor import CheckResult, STATUS_FAIL, STATUS_OK
        mock_conn.return_value = CheckResult("api_health", STATUS_FAIL, "unreachable")
        mock_key.return_value = CheckResult("api_key", STATUS_OK, "ok")
        runner = CliRunner()
        result = runner.invoke(main, ["doctor"])
        assert result.exit_code == 1

    def test_doctor_help(self):
        runner = CliRunner()
        result = runner.invoke(main, ["doctor", "--help"])
        assert result.exit_code == 0
        assert "--manifest" in result.output
        assert "--json" in result.output


class TestMergeCommand:
    def test_merge_two_manifests(self, tmp_path):
        m1 = Manifest(root_dir="/server1")
        m1.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        mp1 = tmp_path / "m1.db"
        m1.save(mp1)
        m1.close()

        m2 = Manifest(root_dir="/server2")
        m2.add(ManifestEntry(hash_hex="bb" * 32, path="b.txt", size=200, mtime=1.0))
        mp2 = tmp_path / "m2.db"
        m2.save(mp2)
        m2.close()

        out = tmp_path / "merged.db"
        runner = CliRunner()
        result = runner.invoke(main, ["merge", str(mp1), str(mp2), "-o", str(out)])
        assert result.exit_code == 0
        assert out.exists()
        with Manifest.load(out) as merged:
            assert merged.total == 2

    def test_merge_json_output(self, tmp_path):
        m1 = Manifest(root_dir="/a")
        m1.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        mp1 = tmp_path / "m1.db"
        m1.save(mp1)
        m1.close()

        m2 = Manifest(root_dir="/b")
        m2.add(ManifestEntry(hash_hex="bb" * 32, path="b.txt", size=200, mtime=1.0))
        mp2 = tmp_path / "m2.db"
        m2.save(mp2)
        m2.close()

        out = tmp_path / "merged.db"
        runner = CliRunner()
        result = runner.invoke(main, ["merge", str(mp1), str(mp2), "-o", str(out), "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["total_entries"] == 2
        assert data["sources"] == 2

    def test_merge_preserves_attestation(self, tmp_path):
        m1 = Manifest(root_dir="/data")
        m1.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        m1.mark_attested("aa" * 32, "att_123", "2026-01-01")
        mp1 = tmp_path / "m1.db"
        m1.save(mp1)
        m1.close()

        m2 = Manifest(root_dir="/data")
        m2.add(ManifestEntry(hash_hex="bb" * 32, path="b.txt", size=200, mtime=1.0))
        mp2 = tmp_path / "m2.db"
        m2.save(mp2)
        m2.close()

        out = tmp_path / "merged.db"
        runner = CliRunner()
        runner.invoke(main, ["merge", str(mp1), str(mp2), "-o", str(out)])
        with Manifest.load(out) as merged:
            entry = merged.get("aa" * 32)
            assert entry is not None
            assert entry.attested
            assert entry.attestation_id == "att_123"

    def test_merge_requires_two_manifests(self, tmp_path):
        mp = tmp_path / "m1.db"
        Manifest(root_dir="/data").save(mp)
        out = tmp_path / "out.db"
        runner = CliRunner()
        result = runner.invoke(main, ["merge", str(mp), "-o", str(out)])
        assert result.exit_code != 0

    def test_merge_missing_manifest(self, tmp_path):
        mp = tmp_path / "m1.db"
        Manifest(root_dir="/data").save(mp)
        out = tmp_path / "out.db"
        runner = CliRunner()
        result = runner.invoke(main, ["merge", str(mp), str(tmp_path / "missing.db"), "-o", str(out)])
        assert result.exit_code != 0

    def test_merge_help(self):
        runner = CliRunner()
        result = runner.invoke(main, ["merge", "--help"])
        assert result.exit_code == 0
        assert "--output" in result.output
        assert "--json" in result.output


class TestStatusJson:
    def test_status_json_output(self, tmp_path):
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        m.mark_attested("aa" * 32, "att_1", "2026-01-01")
        m.add(ManifestEntry(hash_hex="bb" * 32, path="b.txt", size=200, mtime=1.0))
        mp = tmp_path / "test.db"
        m.save(mp)
        m.close()

        runner = CliRunner()
        result = runner.invoke(main, ["status", str(mp), "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["total"] == 2
        assert data["attested"] == 1
        assert data["pending"] == 1


class TestScanJson:
    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_scan_json_dry_run(self, _mock_hash, tmp_path):
        scan_dir = tmp_path / "data"
        scan_dir.mkdir()
        (scan_dir / "a.txt").write_bytes(b"aaa")
        (scan_dir / "b.txt").write_bytes(b"bbb")

        runner = CliRunner()
        mp = str(tmp_path / "manifest.db")
        result = runner.invoke(main, ["scan", str(scan_dir), "--dry-run", "--manifest", mp, "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["total"] == 2
        assert data["dry_run"] is True
        assert "Scanning" not in result.output


class TestCompareJson:
    @patch("certisigma_census.cli._get_client")
    def test_compare_json_output(self, mock_get_client, tmp_path):
        client = MagicMock()
        client.batch_verify.return_value = MagicMock(results=[])
        mock_get_client.return_value = client

        suspect = tmp_path / "suspect"
        suspect.mkdir()
        (suspect / "a.txt").write_bytes(b"aaa")

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            result = runner.invoke(main, ["compare", str(suspect), "--json"])
            output = result.output
            json_start = output.index("{")
            data = json.loads(output[json_start:])
            assert "total_suspect" in data
            assert "matches" in data
        finally:
            logging.disable(logging.NOTSET)
