"""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 import __version__
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, **kwargs):
    """Build a BatchVerifyResult mock."""
    results = [
        {"hash_hex": h, "exists": all_exist, "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 __version__ in result.output


class TestNoColor:
    def test_no_color_flag(self, tmp_path):
        """--no-color disables colored output."""
        runner = CliRunner()
        result = runner.invoke(main, ["--no-color", "--version"])
        assert result.exit_code == 0
        assert "certisigma-census" in result.output

    def test_no_color_env(self, tmp_path):
        """NO_COLOR env var disables colored output."""
        runner = CliRunner(env={"NO_COLOR": "1"})
        result = runner.invoke(main, ["--version"])
        assert result.exit_code == 0


class TestJsonMeta:
    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_json_has_version(self, mock_get_client, _mock_hash, populated_dir):
        """JSON output includes census_version field."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=False, **kw)
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["compare", str(populated_dir), "--json"])
        json_start = result.output.index("{")
        data = json.loads(result.output[json_start:])
        assert data["census_version"] == __version__
        assert "elapsed_seconds" in data
        assert isinstance(data["elapsed_seconds"], (int, float))

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_text_has_elapsed(self, mock_get_client, _mock_hash, populated_dir):
        """Text output includes elapsed time."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=False, **kw)
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["compare", str(populated_dir), "--exit-zero"])
        assert "Completed in" 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)
        m.close()

        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)
        m.close()

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

        with Manifest.load(Path(manifest_path)) as manifest:
            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()

        with Manifest.load(Path(manifest_path)) as manifest:
            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, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        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, **kw: _mock_batch_verify_response(hashes, all_exist=False, **kw)
        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, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        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)
        m.close()

        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, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        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 TestCompareCIOptions:
    """Tests for compare --exit-zero and --summary (CI/CD integration)."""

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_exit_zero_with_matches(self, mock_get_client, _mock_hash, populated_dir):
        """--exit-zero returns 0 even when matches are found."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["compare", str(populated_dir), "--exit-zero"])
        assert result.exit_code == 0
        assert "Matched:" in result.output

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_exit_zero_no_matches(self, mock_get_client, _mock_hash, populated_dir):
        """--exit-zero with no matches still returns 0."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=False, **kw)
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["compare", str(populated_dir), "--exit-zero"])
        assert result.exit_code == 0

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_summary_hides_details(self, mock_get_client, _mock_hash, populated_dir):
        """--summary shows counts but no individual match lines."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["compare", str(populated_dir), "--summary"])
        assert result.exit_code == 1
        assert "Matched:" in result.output
        assert "--- Matches ---" not in result.output

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_summary_with_exit_zero(self, mock_get_client, _mock_hash, populated_dir):
        """--summary --exit-zero: counts only, exit 0."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, [
            "compare", str(populated_dir), "--summary", "--exit-zero",
        ])
        assert result.exit_code == 0
        assert "Matched:" in result.output
        assert "--- Matches ---" not in result.output

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_exit_zero_with_sarif(self, mock_get_client, _mock_hash, populated_dir):
        """--exit-zero --format sarif: SARIF generated, exit 0."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        mock_get_client.return_value = client

        out_file = str(populated_dir / "report.sarif")
        runner = CliRunner()
        result = runner.invoke(main, [
            "compare", str(populated_dir),
            "--format", "sarif", "--output", out_file, "--exit-zero",
        ])
        assert result.exit_code == 0
        sarif = json.loads(Path(out_file).read_text())
        assert sarif["version"] == "2.1.0"
        assert len(sarif["runs"][0]["results"]) > 0

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_summary_does_not_affect_json(self, mock_get_client, _mock_hash, populated_dir):
        """--summary --json: JSON still contains complete matches."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, [
            "compare", str(populated_dir), "--summary", "--json", "--exit-zero",
        ])
        assert result.exit_code == 0
        json_start = result.output.index("{")
        data = json.loads(result.output[json_start:])
        assert data["matched"] == 5
        assert len(data["matches"]) == 5


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)
        m.close()
        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)
        m.close()
        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)
        m.close()

        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
        with Manifest.load(Path(manifest_path)) as manifest:
            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
        with Manifest.load(Path(manifest_path)) as manifest:
            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
        with Manifest.load(Path(manifest_path)) as manifest:
            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")
        m.close()
        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")
        m.close()

        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)
        m.close()

        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)
        m.close()

        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)
        m.close()

        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)
        m.close()

        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)
        m.close()

        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

    @patch("certisigma_census.evidence.hash_file")
    def test_integrity_json_output_write_fails_exit_1(self, mock_hash, tmp_path):
        """--json + --output: write failure must not emit success JSON 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)
        m.close()

        bad = tmp_path / "isdir"
        bad.mkdir()
        runner = CliRunner()
        result = runner.invoke(main, ["integrity", str(mp), "--json", "--output", str(bad)])
        assert result.exit_code == 1
        data = json.loads(result.output)
        assert "error" in data
        assert "Cannot write report" in data["error"]

    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)
        m.close()

        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)
        m.close()

        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)
        m.close()

        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)
        base.close()

        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)
        target.close()

        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)
        base.close()

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

        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)
        base.close()
        target = Manifest(root_dir="/data")
        mp2 = tmp_path / "target.db"
        target.save(mp2)
        target.close()

        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)
        base.close()
        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)
        target.close()

        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)
        base.close()
        target = Manifest(root_dir="/data")
        mp2 = tmp_path / "target.db"
        target.save(mp2)
        target.close()

        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)
        base.close()
        target = Manifest(root_dir="/data")
        mp2 = tmp_path / "target.db"
        target.save(mp2)
        target.close()

        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_json_output_write_fails_exit_1(self, tmp_path):
        """--json + -o: write failure must not emit diff JSON 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)
        base.close()
        target = Manifest(root_dir="/data")
        mp2 = tmp_path / "target.db"
        target.save(mp2)
        target.close()

        bad = tmp_path / "isdir"
        bad.mkdir()
        runner = CliRunner()
        result = runner.invoke(main, ["diff", str(mp1), str(mp2), "--json", "-o", str(bad)])
        assert result.exit_code == 1
        data = json.loads(result.output)
        assert "error" in data
        assert "Cannot write report" in data["error"]

    def test_diff_missing_manifest(self, tmp_path):
        """census diff with missing manifest exits with error."""
        mp1 = tmp_path / "exists.db"
        m = Manifest(root_dir="/data")
        m.save(mp1)
        m.close()
        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 with files array and metadata."""
        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, dict)
        assert "files" in data
        assert "census_version" in data
        assert "elapsed_seconds" in data
        assert len(data["files"]) == 1
        assert data["files"][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
        with Manifest.load(Path(manifest_path)) as m1:
            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
        with Manifest.load(Path(manifest_path)) as m2:
            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
        with Manifest.load(Path(manifest_path)) as m:
            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()

    @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"
        m = Manifest(root_dir="/data")
        m.save(mp)
        m.close()
        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"
        m = Manifest(root_dir="/data")
        m.save(mp)
        m.close()
        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)

    @patch("certisigma_census.cli._get_client")
    def test_compare_json_with_output_warns_on_stderr(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"a")
        out = tmp_path / "rep.json"

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            result = runner.invoke(
                main,
                ["compare", str(suspect), "--json", "--output", str(out)],
            )
            assert result.exit_code == 0
            assert "ignored" in result.stderr.lower()
            assert not out.exists()
        finally:
            logging.disable(logging.NOTSET)


class TestAuditLogCommand:
    def test_show_empty(self, tmp_path):
        log = tmp_path / "audit.jsonl"
        runner = CliRunner()
        result = runner.invoke(main, ["audit-log", "show", "--log-path", str(log)])
        assert result.exit_code == 0
        assert "empty" in result.output.lower()

    def test_show_after_entries(self, tmp_path):
        from certisigma_census.audit_log import append_entry
        log = tmp_path / "audit.jsonl"
        append_entry("scan", {"dir": "/data"}, log_path=log)
        append_entry("compare", {"dir": "/suspect"}, log_path=log)

        runner = CliRunner()
        result = runner.invoke(main, ["audit-log", "show", "--log-path", str(log)])
        assert result.exit_code == 0
        assert "scan" in result.output
        assert "compare" in result.output

    def test_show_last_n(self, tmp_path):
        from certisigma_census.audit_log import append_entry
        log = tmp_path / "audit.jsonl"
        for i in range(10):
            append_entry(f"action_{i}", log_path=log)

        runner = CliRunner()
        result = runner.invoke(
            main, ["audit-log", "show", "--log-path", str(log), "--last", "3"]
        )
        assert result.exit_code == 0
        assert "action_7" in result.output
        assert "action_9" in result.output
        assert "action_0" not in result.output

    def test_show_json(self, tmp_path):
        from certisigma_census.audit_log import append_entry
        log = tmp_path / "audit.jsonl"
        append_entry("test", log_path=log)

        runner = CliRunner()
        result = runner.invoke(
            main, ["audit-log", "show", "--log-path", str(log), "--json"]
        )
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert isinstance(data, list)
        assert data[0]["action"] == "test"

    def test_verify_intact_chain(self, tmp_path):
        from certisigma_census.audit_log import append_entry
        log = tmp_path / "audit.jsonl"
        append_entry("a", log_path=log)
        append_entry("b", log_path=log)

        runner = CliRunner()
        result = runner.invoke(main, ["audit-log", "verify", "--log-path", str(log)])
        assert result.exit_code == 0
        assert "intact" in result.output.lower()

    def test_verify_json(self, tmp_path):
        from certisigma_census.audit_log import append_entry
        log = tmp_path / "audit.jsonl"
        append_entry("a", log_path=log)

        runner = CliRunner()
        result = runner.invoke(
            main, ["audit-log", "verify", "--log-path", str(log), "--json"]
        )
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["total"] == 1
        assert data["valid"] == 1

    def test_clear(self, tmp_path):
        from certisigma_census.audit_log import append_entry
        log = tmp_path / "audit.jsonl"
        append_entry("a", log_path=log)
        assert log.exists()

        runner = CliRunner()
        result = runner.invoke(main, ["audit-log", "clear", "--log-path", str(log)])
        assert result.exit_code == 0
        assert not log.exists()


class TestSnapshotCommand:
    def _make_manifest(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path / "data"))
        mpath = tmp_path / "manifest.db"
        m.save(mpath)
        m.close()
        return mpath

    def test_create_and_list(self, tmp_path):
        mpath = self._make_manifest(tmp_path)
        sdir = tmp_path / "snapshots"

        runner = CliRunner()
        result = runner.invoke(main, [
            "snapshot", "create", "q1",
            "--manifest", str(mpath), "--snapshot-dir", str(sdir),
        ])
        assert result.exit_code == 0
        assert "q1" in result.output

        result = runner.invoke(main, [
            "snapshot", "list", "--snapshot-dir", str(sdir),
        ])
        assert result.exit_code == 0
        assert "q1" in result.output

    def test_create_json(self, tmp_path):
        mpath = self._make_manifest(tmp_path)
        sdir = tmp_path / "snapshots"

        runner = CliRunner()
        result = runner.invoke(main, [
            "snapshot", "create", "base",
            "--manifest", str(mpath), "--snapshot-dir", str(sdir), "--json",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["name"] == "base"

    def test_delete(self, tmp_path):
        mpath = self._make_manifest(tmp_path)
        sdir = tmp_path / "snapshots"

        runner = CliRunner()
        runner.invoke(main, [
            "snapshot", "create", "del-me",
            "--manifest", str(mpath), "--snapshot-dir", str(sdir),
        ])
        result = runner.invoke(main, [
            "snapshot", "delete", "del-me", "--snapshot-dir", str(sdir),
        ])
        assert result.exit_code == 0
        assert "deleted" in result.output.lower()

    def test_diff(self, tmp_path):
        mpath = self._make_manifest(tmp_path)
        sdir = tmp_path / "snapshots"

        runner = CliRunner()
        runner.invoke(main, [
            "snapshot", "create", "a",
            "--manifest", str(mpath), "--snapshot-dir", str(sdir),
        ])
        runner.invoke(main, [
            "snapshot", "create", "b",
            "--manifest", str(mpath), "--snapshot-dir", str(sdir),
        ])
        result = runner.invoke(main, [
            "snapshot", "diff", "a", "b", "--snapshot-dir", str(sdir),
        ])
        assert result.exit_code == 0
        assert "unchanged" in result.output.lower()

    def test_create_no_manifest_errors(self, tmp_path):
        sdir = tmp_path / "snapshots"
        runner = CliRunner()
        result = runner.invoke(main, [
            "snapshot", "create", "q1", "--snapshot-dir", str(sdir),
        ])
        assert result.exit_code != 0

    def test_list_empty(self, tmp_path):
        sdir = tmp_path / "snapshots"
        runner = CliRunner()
        result = runner.invoke(main, [
            "snapshot", "list", "--snapshot-dir", str(sdir),
        ])
        assert result.exit_code == 0
        assert "no snapshots" in result.output.lower()


class TestAnnotateCommand:
    @patch("certisigma.CertiSigmaClient")
    def test_basic_annotate(self, mock_cls):
        from tests.test_annotate import _MockMetadataResult

        client = MagicMock()
        mock_cls.return_value = client
        client.update_metadata.return_value = _MockMetadataResult(
            source="forensics", extra_data={"note": "test"},
        )

        runner = CliRunner()
        result = runner.invoke(main, [
            "annotate", "att_123", "--note", "test", "--source", "forensics",
            "--api-key", "cs_test",
        ])
        assert result.exit_code == 0
        assert "updated" in result.output.lower()

    @patch("certisigma.CertiSigmaClient")
    def test_annotate_delete(self, mock_cls):
        from tests.test_annotate import _MockMetadataResult

        client = MagicMock()
        mock_cls.return_value = client
        client.delete_metadata.return_value = _MockMetadataResult(deleted=True)

        runner = CliRunner()
        result = runner.invoke(main, [
            "annotate", "att_123", "--delete", "--api-key", "cs_test",
        ])
        assert result.exit_code == 0
        assert "deleted" in result.output.lower()

    @patch("certisigma.CertiSigmaClient")
    def test_annotate_json(self, mock_cls):
        from tests.test_annotate import _MockMetadataResult

        client = MagicMock()
        mock_cls.return_value = client
        client.update_metadata.return_value = _MockMetadataResult(
            extra_data={"note": "json-test"},
        )

        runner = CliRunner()
        result = runner.invoke(main, [
            "annotate", "att_123", "--note", "json-test",
            "--json", "--api-key", "cs_test",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["success"]

    def test_annotate_no_options_errors(self):
        runner = CliRunner()
        result = runner.invoke(main, [
            "annotate", "att_123", "--api-key", "cs_test",
        ])
        assert result.exit_code != 0


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

class TestShareCommand:
    @patch("certisigma.CertiSigmaClient")
    def test_create_success(self, MockClient):
        mock_result = MagicMock()
        mock_result.id = "shr_123"
        mock_result.share_token = "tok_abc"
        mock_result.attestation_ids = ["att_1"]
        mock_result.expires_at = "2026-04-01"
        MockClient.return_value.create_share_token.return_value = mock_result

        runner = CliRunner()
        result = runner.invoke(main, [
            "share", "create", "att_1",
            "--api-key", "cs_test",
            "--expires", "24h",
        ])
        assert result.exit_code == 0
        assert "shr_123" in result.output

    @patch("certisigma.CertiSigmaClient")
    def test_create_json(self, MockClient):
        mock_result = MagicMock()
        mock_result.id = "shr_123"
        mock_result.share_token = "tok_abc"
        mock_result.attestation_ids = ["att_1"]
        mock_result.expires_at = "2026-04-01"
        MockClient.return_value.create_share_token.return_value = mock_result

        runner = CliRunner()
        result = runner.invoke(main, [
            "share", "create", "att_1",
            "--api-key", "cs_test",
            "--json",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["success"] is True

    @patch("certisigma.CertiSigmaClient")
    def test_list_tokens(self, MockClient):
        t = MagicMock()
        t.id = "shr_1"
        t.attestation_ids = ["att_1"]
        t.recipient_label = "Legal"
        t.max_uses = None
        t.use_count = 0
        t.expires_at = "2026-04-01"
        t.revoked_at = None
        t.created_at = "2026-03-01"
        t.active = True
        MockClient.return_value.list_share_tokens.return_value = [t]

        runner = CliRunner()
        result = runner.invoke(main, [
            "share", "list", "--api-key", "cs_test", "--json",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert len(data) == 1

    @patch("certisigma.CertiSigmaClient")
    def test_revoke(self, MockClient):
        MockClient.return_value.revoke_share_token.return_value = None
        runner = CliRunner()
        result = runner.invoke(main, [
            "share", "revoke", "shr_1",
            "--api-key", "cs_test",
        ])
        assert result.exit_code == 0
        assert "revoked" in result.output

    def test_create_no_ids(self):
        runner = CliRunner()
        result = runner.invoke(main, ["share", "create", "--api-key", "cs_test"])
        assert result.exit_code != 0


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

class TestTagCommand:
    @patch("certisigma.CertiSigmaClient")
    def test_set_success(self, MockClient):
        mock_result = MagicMock()
        mock_result.tags_upserted = 2
        MockClient.return_value.put_tags.return_value = mock_result

        runner = CliRunner()
        result = runner.invoke(main, [
            "tag", "set", "att_1",
            "-t", "dept=legal", "-t", "case=001",
            "--api-key", "cs_test",
        ])
        assert result.exit_code == 0
        assert "2 tag" in result.output

    @patch("certisigma.CertiSigmaClient")
    def test_get_json(self, MockClient):
        t = MagicMock()
        t.key = "dept"
        t.value = "legal"
        t.client_encrypted = False
        t.value_enc = None
        t.value_nonce = None
        t.created_at = "2026-03-01"
        t.updated_at = "2026-03-01"
        MockClient.return_value.get_tags.return_value = [t]

        runner = CliRunner()
        result = runner.invoke(main, [
            "tag", "get", "att_1",
            "--api-key", "cs_test", "--json",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data[0]["key"] == "dept"

    @patch("certisigma.CertiSigmaClient")
    def test_delete(self, MockClient):
        MockClient.return_value.delete_tag.return_value = None
        runner = CliRunner()
        result = runner.invoke(main, [
            "tag", "delete", "att_1", "dept",
            "--api-key", "cs_test",
        ])
        assert result.exit_code == 0
        assert "deleted" in result.output

    @patch("certisigma.CertiSigmaClient")
    def test_query_json(self, MockClient):
        mock_result = MagicMock()
        mock_result.attestation_ids = ["att_1", "att_2"]
        mock_result.next_cursor = None
        mock_result.count = 2
        MockClient.return_value.query_tags.return_value = mock_result

        runner = CliRunner()
        result = runner.invoke(main, [
            "tag", "query",
            "-f", "dept=legal",
            "--api-key", "cs_test", "--json",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["count"] == 2

    def test_set_no_tags(self):
        runner = CliRunner()
        result = runner.invoke(main, [
            "tag", "set", "att_1", "--api-key", "cs_test",
        ])
        assert result.exit_code != 0

    def test_delete_missing_key(self):
        runner = CliRunner()
        result = runner.invoke(main, [
            "tag", "delete", "att_1", "--api-key", "cs_test",
        ])
        assert result.exit_code != 0


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

class TestKeyRotateCommand:
    @patch("certisigma.CertiSigmaClient")
    @patch("certisigma.crypto.is_encrypted", return_value=True)
    @patch("certisigma.crypto.rotate_key")
    def test_success(self, mock_rotate, mock_is_enc, MockClient):
        client = MockClient.return_value
        meta = MagicMock()
        meta.extra_data = {"ciphertext": "ct", "nonce": "n", "v": 1}
        client.get_metadata.return_value = meta
        client.get_tags.return_value = []
        mock_rotate.return_value = {"ciphertext": "new", "nonce": "new_n", "v": 1}

        runner = CliRunner()
        result = runner.invoke(main, [
            "key-rotate", "att_1",
            "--old-key", "aa" * 32,
            "--new-key", "bb" * 32,
            "--api-key", "cs_test",
        ])
        assert result.exit_code == 0
        assert "rotated" in result.output.lower() or "metadata" in result.output.lower()

    def test_invalid_key_length(self):
        runner = CliRunner()
        result = runner.invoke(main, [
            "key-rotate", "att_1",
            "--old-key", "short",
            "--new-key", "bb" * 32,
        ])
        assert result.exit_code != 0

    @patch("certisigma.CertiSigmaClient")
    @patch("certisigma.crypto.is_encrypted", return_value=True)
    @patch("certisigma.crypto.rotate_key")
    def test_json_output(self, mock_rotate, mock_is_enc, MockClient):
        client = MockClient.return_value
        meta = MagicMock()
        meta.extra_data = {"ciphertext": "ct", "nonce": "n", "v": 1}
        client.get_metadata.return_value = meta
        client.get_tags.return_value = []
        mock_rotate.return_value = {"ciphertext": "new", "nonce": "new_n", "v": 1}

        runner = CliRunner()
        result = runner.invoke(main, [
            "key-rotate", "att_1",
            "--old-key", "aa" * 32,
            "--new-key", "bb" * 32,
            "--api-key", "cs_test",
            "--json",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["success"] is True


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

class TestDerivedListCommand:
    @patch("certisigma.CertiSigmaClient")
    def test_create_from_manifest(self, MockClient, tmp_path):
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=100, mtime=1.0))
        m.add(ManifestEntry(hash_hex="bb" * 32, path="g.txt", size=200, mtime=2.0))
        mpath = tmp_path / "manifest.db"
        m.save(mpath)
        m.close()

        mock_result = MagicMock()
        mock_result.id = "dl_123"
        mock_result.list_key = "cc" * 32
        mock_result.item_count = 2
        mock_result.content_hash = "dd" * 32
        mock_result.signature = "sig"
        mock_result.label = "test"
        mock_result.expires_at = "2026-06-01"
        mock_result.warning = None
        MockClient.return_value.create_derived_list.return_value = mock_result

        runner = CliRunner()
        result = runner.invoke(main, [
            "derived-list", "create",
            "--manifest", str(mpath),
            "--label", "test",
            "--api-key", "cs_test",
        ])
        assert result.exit_code == 0
        assert "dl_123" in result.output

    @patch("certisigma.CertiSigmaClient")
    def test_list_json(self, MockClient):
        item = MagicMock()
        item.id = "dl_1"
        item.item_count = 10
        item.label = "Q1"
        item.expires_at = "2026-06-01"
        item.revoked_at = None
        item.created_at = "2026-03-01"
        item.active = True
        MockClient.return_value.list_derived_lists.return_value = [item]

        runner = CliRunner()
        result = runner.invoke(main, [
            "derived-list", "list",
            "--api-key", "cs_test", "--json",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert len(data) == 1

    @patch("certisigma.CertiSigmaClient")
    def test_match(self, MockClient, tmp_path):
        mock_result = MagicMock()
        mock_result.matched = 1
        mock_result.unmatched = 1
        mock_result.total = 2
        mock_result.matches = [{"derived_hash": "h1"}]
        MockClient.return_value.match_derived_list.return_value = mock_result

        hfile = tmp_path / "suspects.txt"
        hfile.write_text("aa" * 32 + "\n" + "bb" * 32 + "\n")

        runner = CliRunner()
        result = runner.invoke(main, [
            "derived-list", "match", "dl_1",
            "--list-key", "cc" * 32,
            "--hashes-file", str(hfile),
            "--api-key", "cs_test",
        ])
        assert result.exit_code == 0
        assert "Matched" in result.output

    @patch("certisigma.CertiSigmaClient")
    def test_revoke(self, MockClient):
        MockClient.return_value.revoke_derived_list.return_value = None
        runner = CliRunner()
        result = runner.invoke(main, [
            "derived-list", "revoke", "dl_1",
            "--api-key", "cs_test",
        ])
        assert result.exit_code == 0
        assert "revoked" in result.output

    def test_create_no_manifest_no_filter(self):
        runner = CliRunner()
        result = runner.invoke(main, [
            "derived-list", "create",
            "--api-key", "cs_test",
        ])
        assert result.exit_code != 0


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

class TestMetadataCommand:
    @patch("certisigma.CertiSigmaClient")
    def test_get_json(self, MockClient):
        meta = MagicMock()
        meta.attestation_id = "att_1"
        meta.source = "census"
        meta.extra_data = {"note": "hello"}
        meta.client_encrypted = False
        meta.created_at = "2026-03-01"
        meta.updated_at = "2026-03-01"
        MockClient.return_value.get_metadata.return_value = meta

        runner = CliRunner()
        result = runner.invoke(main, [
            "metadata", "get", "att_1",
            "--api-key", "cs_test", "--json",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["source"] == "census"

    @patch("certisigma.CertiSigmaClient")
    def test_get_error(self, MockClient):
        from certisigma import CertiSigmaError
        MockClient.return_value.get_metadata.side_effect = CertiSigmaError("not found")

        runner = CliRunner()
        result = runner.invoke(main, [
            "metadata", "get", "att_bad",
            "--api-key", "cs_test",
        ])
        assert result.exit_code != 0


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

class TestKeyGenCommand:
    @patch("certisigma.crypto.generate_key", return_value="aa" * 32)
    def test_key_gen_plain(self, mock_gen):
        runner = CliRunner()
        result = runner.invoke(main, ["key-gen"])
        assert result.exit_code == 0
        assert ("aa" * 32) in result.output

    @patch("certisigma.crypto.generate_key", return_value="bb" * 32)
    def test_key_gen_json(self, mock_gen):
        runner = CliRunner()
        result = runner.invoke(main, ["key-gen", "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["key"] == "bb" * 32
        assert data["algorithm"] == "AES-256-GCM"
        assert data["bits"] == 256


# ─── census derived-list signature CLI ───────────────────────────────────

class TestDerivedListSignatureCommand:
    @patch("certisigma.CertiSigmaClient")
    def test_signature_json(self, MockClient):
        sig = MagicMock()
        sig.list_id = "dl_1"
        sig.signature = "ecdsa_sig_hex"
        sig.signing_key_id = "key_01"
        sig.content_hash = "aa" * 32
        sig.item_count = 10
        sig.created_at = "2026-03-18T00:00:00Z"
        sig.expires_at = "2026-06-18T00:00:00Z"
        MockClient.return_value.get_derived_list_signature.return_value = sig

        runner = CliRunner()
        result = runner.invoke(main, [
            "derived-list", "signature", "dl_1", "--json",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["success"] is True
        assert data["signature"] == "ecdsa_sig_hex"
        assert data["item_count"] == 10

    @patch("certisigma.CertiSigmaClient")
    def test_signature_error(self, MockClient):
        from certisigma import CertiSigmaError
        MockClient.return_value.get_derived_list_signature.side_effect = CertiSigmaError("nope")

        runner = CliRunner()
        result = runner.invoke(main, [
            "derived-list", "signature", "dl_bad",
        ])
        assert result.exit_code != 0


# ─── census compare --detailed CLI ───────────────────────────────────────

class TestCompareDetailedCommand:
    @patch("certisigma_census.compare.hash_file")
    @patch("certisigma_census.cli._get_client")
    def test_detailed_includes_source(self, mock_get_client, mock_hash, tmp_path):
        suspect = tmp_path / "suspect"
        suspect.mkdir()
        (suspect / "leaked.pdf").write_bytes(b"content")

        mock_hash.return_value = "cc" * 32

        client = MagicMock()
        batch_result = MagicMock()
        batch_result.results = [{
            "exists": True,
            "hash_hex": "cc" * 32,
            "id": "att_999",
            "timestamp": "2026-01-01",
            "level": "T2",
            "source": "inventory-hr",
        }]
        client.batch_verify.return_value = batch_result
        mock_get_client.return_value = client

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            result = runner.invoke(main, [
                "compare", str(suspect),
                "--detailed", "--json",
            ])
            assert result.exit_code != 0  # exit(1) when matched > 0
            output = result.output
            json_start = output.index("{")
            data = json.loads(output[json_start:])
            assert data["matched"] == 1
            assert data["matches"][0]["source"] == "inventory-hr"
        finally:
            logging.disable(logging.NOTSET)


# ─── census scan --workers CLI ───────────────────────────────────────────

class TestScanWorkersCommand:
    @patch("certisigma_census.cli._get_client")
    def test_scan_workers_flag(self, mock_get_client, tmp_path):
        (tmp_path / "a.txt").write_text("hello")
        (tmp_path / "b.txt").write_text("world")

        mock_get_client.return_value = MagicMock()

        runner = CliRunner()
        result = runner.invoke(main, [
            "scan", str(tmp_path),
            "--workers", "2", "--json", "--dry-run",
        ])
        assert result.exit_code == 0


# ─── census verify-manifest CLI ────────────────────────────────────────

class TestVerifyManifestCommand:
    @patch("certisigma_census.cli._get_client")
    def test_all_verified_text(self, mock_get_client, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        (tmp_path / "a.txt").write_text("hello")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=5, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        client = MagicMock()
        batch_result = MagicMock()
        batch_result.results = [
            {"hash_hex": "aa" * 32, "exists": True, "id": "att_1", "level": "T2", "timestamp": "2026-01-01"},
        ]
        client.batch_verify.return_value = batch_result
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["verify-manifest", str(mpath)])
        assert result.exit_code == 0
        assert "ALL 1 hashes verified" in result.output

    @patch("certisigma_census.cli._get_client")
    def test_not_found_strict_exit_1(self, mock_get_client, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="bb" * 32, path="b.txt", size=5, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        client = MagicMock()
        batch_result = MagicMock()
        batch_result.results = [{"hash_hex": "bb" * 32, "exists": False}]
        client.batch_verify.return_value = batch_result
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["verify-manifest", str(mpath), "--strict"])
        assert result.exit_code == 1

    @patch("certisigma_census.cli._get_client")
    def test_json_output(self, mock_get_client, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="cc" * 32, path="c.txt", size=10, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        client = MagicMock()
        batch_result = MagicMock()
        batch_result.results = [
            {"hash_hex": "cc" * 32, "exists": True, "id": "att_1", "level": "T1"},
        ]
        client.batch_verify.return_value = batch_result
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["verify-manifest", str(mpath), "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["total"] == 1
        assert data["verified"] == 1

    @patch("certisigma_census.cli._get_client")
    def test_csv_output(self, mock_get_client, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="dd" * 32, path="d.txt", size=10, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        client = MagicMock()
        batch_result = MagicMock()
        batch_result.results = [
            {"hash_hex": "dd" * 32, "exists": True, "id": "att_1", "level": "T2"},
        ]
        client.batch_verify.return_value = batch_result
        mock_get_client.return_value = client

        out_csv = tmp_path / "report.csv"
        runner = CliRunner()
        result = runner.invoke(main, [
            "verify-manifest", str(mpath), "-o", str(out_csv),
        ])
        assert result.exit_code == 0
        assert out_csv.exists()
        content = out_csv.read_text()
        assert "verified" in content
        assert "dd" * 32 in content

    @patch("certisigma_census.cli._get_client")
    def test_output_write_failure_exits_1(self, mock_get_client, tmp_path):
        """-o to a directory must fail before stdout JSON (no false success)."""
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="ee" * 32, path="e.txt", size=1, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        client = MagicMock()
        batch_result = MagicMock()
        batch_result.results = [
            {"hash_hex": "ee" * 32, "exists": True, "id": "att_1", "level": "T2"},
        ]
        client.batch_verify.return_value = batch_result
        mock_get_client.return_value = client

        bad_out = tmp_path / "is_a_dir"
        bad_out.mkdir()
        runner = CliRunner()
        result = runner.invoke(main, [
            "verify-manifest", str(mpath), "--json", "-o", str(bad_out),
        ])
        assert result.exit_code == 1
        data = json.loads(result.output)
        assert "error" in data
        assert "Cannot write report" in data["error"]


class TestVerifyManifestJsonl:
    """Tests for verify-manifest --format jsonl summary metadata parity."""

    @patch("certisigma_census.cli._get_client")
    def test_jsonl_summary_has_report_totals(self, mock_get_client, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=5, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        client = MagicMock()
        batch_result = MagicMock()
        batch_result.results = [
            {"hash_hex": "aa" * 32, "exists": True, "id": "att_1", "level": "T2"},
        ]
        client.batch_verify.return_value = batch_result
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["verify-manifest", str(mpath), "--format", "jsonl"])
        lines = [json.loads(line) for line in result.output.strip().split("\n")]
        summary = lines[-1]
        assert summary["_summary"] is True
        assert "total" in summary
        assert "verified" in summary
        assert "not_found" in summary
        assert "errors" in summary


# ─── census hash --stdin CLI ───────────────────────────────────────────

class TestHashStdinCommand:
    def test_stdin_basic(self):
        runner = CliRunner()
        result = runner.invoke(main, ["hash", "--stdin"], input=b"hello world\n")
        assert result.exit_code == 0
        import hashlib
        expected = hashlib.sha256(b"hello world\n").hexdigest()
        assert expected in result.output

    def test_stdin_json(self):
        runner = CliRunner()
        result = runner.invoke(main, ["hash", "--stdin", "--json"], input=b"test data")
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert "files" in data
        assert "census_version" in data
        assert len(data["files"]) == 1
        assert data["files"][0]["path"] == "<stdin>"
        import hashlib
        assert data["files"][0]["hash"] == hashlib.sha256(b"test data").hexdigest()

    def test_stdin_verify_match(self):
        import hashlib
        content = b"verify me"
        expected = hashlib.sha256(content).hexdigest()
        runner = CliRunner()
        result = runner.invoke(main, ["hash", "--stdin", "--verify", expected], input=content)
        assert result.exit_code == 0
        assert "OK" in result.output

    def test_stdin_verify_mismatch(self):
        runner = CliRunner()
        result = runner.invoke(main, ["hash", "--stdin", "--verify", "00" * 32], input=b"data")
        assert result.exit_code == 1

    def test_stdin_mutually_exclusive_with_target(self, tmp_path):
        f = tmp_path / "file.txt"
        f.write_text("content")
        runner = CliRunner()
        result = runner.invoke(main, ["hash", str(f), "--stdin"])
        assert result.exit_code != 0
        assert "Cannot use --stdin" in result.output

    def test_no_target_no_stdin_error(self):
        runner = CliRunner()
        result = runner.invoke(main, ["hash"])
        assert result.exit_code != 0


# ─── census export --format sha256sum CLI ──────────────────────────────

class TestExportSha256sumCommand:
    def test_sha256sum_stdout(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="aa" * 32, path="file1.txt", size=100, mtime=1.0))
        m.add(ManifestEntry(hash_hex="bb" * 32, path="sub/file2.dat", size=200, mtime=2.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        runner = CliRunner()
        result = runner.invoke(main, ["export", str(mpath), "--format", "sha256sum"])
        assert result.exit_code == 0
        lines = result.output.strip().split("\n")
        assert len(lines) == 2
        for line in lines:
            parts = line.split("  ", 1)
            assert len(parts) == 2
            assert len(parts[0]) == 64

    def test_sha256sum_to_file(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="cc" * 32, path="doc.pdf", size=1000, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        outfile = tmp_path / "checksums.sha256"
        runner = CliRunner()
        result = runner.invoke(main, [
            "export", str(mpath), "--format", "sha256sum", "--output", str(outfile),
        ])
        assert result.exit_code == 0
        content = outfile.read_text()
        assert "cc" * 32 in content
        assert "doc.pdf" in content


# ─── --quiet / -q ──────────────────────────────────────────────────────────

class TestQuietFlag:
    def test_quiet_suppresses_scan_output(self, tmp_path):
        scan_dir = tmp_path / "data"
        scan_dir.mkdir()
        (scan_dir / "a.txt").write_bytes(b"hello")

        runner = CliRunner()
        mp = str(tmp_path / "manifest.db")
        result = runner.invoke(main, [
            "-q", "scan", str(scan_dir), "--dry-run", "--manifest", mp,
        ])
        assert result.exit_code == 0
        assert "Scanning" not in result.output
        assert "Found" not in result.output
        assert "Dry run" not in result.output

    def test_quiet_preserves_json_output(self, tmp_path):
        scan_dir = tmp_path / "data"
        scan_dir.mkdir()
        (scan_dir / "a.txt").write_bytes(b"hello")

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

    def test_quiet_suppresses_status_output(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=10, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        runner = CliRunner()
        result = runner.invoke(main, ["-q", "status", str(mpath)])
        assert result.exit_code == 0
        assert result.output.strip() == ""

    @patch("certisigma_census.cli._get_client")
    def test_quiet_suppresses_compare_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, ["-q", "compare", str(suspect)])
            assert result.exit_code == 0
            assert result.output.strip() == ""
        finally:
            logging.disable(logging.NOTSET)


# ─── --attest-manifest ─────────────────────────────────────────────────────

class TestAttestManifest:
    @patch("certisigma_census.cli._get_client")
    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_attest_manifest_json(self, _mock_hash, mock_get_client, tmp_path):
        client = MagicMock()
        batch_result = MagicMock()
        batch_result.attestations = [
            {"id": "att-1", "hash_hex": _deterministic_hash(str(tmp_path / "data/a.txt")), "timestamp": "2026-01-01"},
        ]
        client.batch_attest.return_value = batch_result

        attest_result = MagicMock()
        attest_result.id = "manifest-att-1"
        client.attest.return_value = attest_result
        mock_get_client.return_value = client

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

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            mp = str(tmp_path / "manifest.db")
            result = runner.invoke(main, [
                "scan", str(scan_dir), "--manifest", mp,
                "--attest-manifest", "--json",
            ])
            assert result.exit_code == 0
            data = json.loads(result.output)
            assert data.get("manifest_attestation_id") == "manifest-att-1"
        finally:
            logging.disable(logging.NOTSET)

    @patch("certisigma_census.cli._get_client")
    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_attest_manifest_skipped_on_dry_run(self, _mock_hash, mock_get_client, tmp_path):
        """--attest-manifest is ignored when --dry-run is set."""
        scan_dir = tmp_path / "data"
        scan_dir.mkdir()
        (scan_dir / "a.txt").write_bytes(b"aaa")

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


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

class TestSealCommand:
    def test_seal_creates_file(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="bb" * 32, path="f.txt", size=100, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        runner = CliRunner()
        key = "aa" * 32
        result = runner.invoke(main, ["seal", str(mpath), "--key", key])
        assert result.exit_code == 0
        assert "Seal created" in result.output

    def test_seal_json_output(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="bb" * 32, path="f.txt", size=100, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        runner = CliRunner()
        key = "aa" * 32
        result = runner.invoke(main, ["seal", str(mpath), "--key", key, "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["algorithm"] == "HMAC-SHA256"
        assert "seal_path" in data

    def test_seal_invalid_key(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        runner = CliRunner()
        result = runner.invoke(main, ["seal", str(mpath), "--key", "short"])
        assert result.exit_code == 1

    def test_seal_missing_manifest(self, tmp_path):
        runner = CliRunner()
        result = runner.invoke(main, ["seal", str(tmp_path / "nope.db"), "--key", "aa" * 32])
        assert result.exit_code != 0


class TestVerifySealCommand:
    def test_valid_seal(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="bb" * 32, path="f.txt", size=100, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        key = "aa" * 32
        runner = CliRunner()
        runner.invoke(main, ["seal", str(mpath), "--key", key])
        result = runner.invoke(main, ["verify-seal", str(mpath), "--key", key])
        assert result.exit_code == 0

    def test_tampered_manifest_fails(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="bb" * 32, path="f.txt", size=100, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        key = "aa" * 32
        runner = CliRunner()
        runner.invoke(main, ["seal", str(mpath), "--key", key])

        m2 = Manifest.load(mpath)
        m2.add(ManifestEntry(hash_hex="cc" * 32, path="new.txt", size=50, mtime=2.0))
        m2.save(mpath)
        m2.close()

        result = runner.invoke(main, ["verify-seal", str(mpath), "--key", key])
        assert result.exit_code == 1

    def test_verify_seal_json(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="bb" * 32, path="f.txt", size=100, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        key = "aa" * 32
        runner = CliRunner()
        runner.invoke(main, ["seal", str(mpath), "--key", key])
        result = runner.invoke(main, ["verify-seal", str(mpath), "--key", key, "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["valid"] is True

    def test_missing_seal_exits_1(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        runner = CliRunner()
        result = runner.invoke(main, ["verify-seal", str(mpath), "--key", "aa" * 32])
        assert result.exit_code == 1

    def test_verify_seal_invalid_key_exits_1(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        runner = CliRunner()
        result = runner.invoke(main, ["verify-seal", str(mpath), "--key", "short"])
        assert result.exit_code == 1
        assert "64 hex" in result.output or "Error" in (result.output + getattr(result, 'stderr', ''))

    def test_verify_seal_invalid_key_json(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        runner = CliRunner()
        result = runner.invoke(main, ["verify-seal", str(mpath), "--key", "short", "--json"])
        assert result.exit_code == 1
        data = json.loads(result.output)
        assert data["valid"] is False
        assert "error" in data

    def test_re_seal_overwrites(self, tmp_path):
        """Re-sealing should overwrite the existing seal."""
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="bb" * 32, path="f.txt", size=100, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        key = "aa" * 32
        runner = CliRunner()
        runner.invoke(main, ["seal", str(mpath), "--key", key])
        result = runner.invoke(main, ["seal", str(mpath), "--key", key])
        assert result.exit_code == 0

        result = runner.invoke(main, ["verify-seal", str(mpath), "--key", key])
        assert result.exit_code == 0

    def test_seal_empty_manifest(self, tmp_path):
        """Sealing a manifest with zero entries must succeed."""
        m = Manifest(root_dir=str(tmp_path))
        mpath = tmp_path / "empty.db"
        m.save(mpath)
        m.close()

        key = "aa" * 32
        runner = CliRunner()
        result = runner.invoke(main, ["seal", str(mpath), "--key", key])
        assert result.exit_code == 0
        assert "Seal created" in result.output

        result = runner.invoke(main, ["verify-seal", str(mpath), "--key", key])
        assert result.exit_code == 0


# ─── --quiet flag: extended coverage ──────────────────────────────────────

class TestQuietFlagExtended:
    """Ensure --quiet suppresses output in commands beyond scan/compare/status."""

    @patch("certisigma_census.evidence.hash_file")
    def test_quiet_integrity(self, mock_hash, tmp_path):
        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)
        m.close()

        runner = CliRunner()
        result = runner.invoke(main, ["-q", "integrity", str(mp)])
        assert result.exit_code == 0
        assert "INTEGRITY OK" not in result.output

    @patch("certisigma_census.evidence.retry_api_call")
    def test_quiet_verify(self, mock_retry, mock_client):
        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-01T00:00:00Z", source="test",
        )
        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, ["-q", "verify", "ab" * 32])
        assert result.exit_code == 0
        assert "Attested" not in result.output

    @patch("certisigma_census.cli._get_client")
    def test_quiet_verify_manifest(self, mock_get_client, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=5, mtime=1.0))
        mpath = tmp_path / "test.db"
        m.save(mpath)
        m.close()

        client = MagicMock()
        batch_result = MagicMock()
        batch_result.results = [
            {"hash_hex": "aa" * 32, "exists": True, "id": "att_1", "level": "T0", "timestamp": "2026-01-01"},
        ]
        client.batch_verify.return_value = batch_result
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["-q", "verify-manifest", str(mpath)])
        assert result.exit_code == 0
        assert "Verified" not in result.output
        assert "ALL" not in result.output

    def test_quiet_diff(self, tmp_path):
        """--quiet suppresses diff output. Use identical manifests for exit 0."""
        m1 = Manifest(root_dir=str(tmp_path))
        m1.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=5, mtime=1.0))
        mp1 = tmp_path / "m1.db"
        m1.save(mp1)

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

        runner = CliRunner()
        result = runner.invoke(main, ["-q", "diff", str(mp1), str(mp2)])
        assert result.exit_code == 0
        assert result.output.strip() == ""


# ─── --attest-manifest extended ────────────────────────────────────────────

class TestAttestManifestExtended:
    @patch("certisigma_census.cli._get_client")
    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_attest_manifest_failure_does_not_abort_scan(self, _mock_hash, mock_get_client, tmp_path):
        """If client.attest() fails during --attest-manifest, the scan still succeeds."""
        client = MagicMock()
        batch_result = MagicMock()
        batch_result.attestations = [
            {"id": "att-1", "hash_hex": _deterministic_hash(str(tmp_path / "data/a.txt")), "timestamp": "2026-01-01"},
        ]
        client.batch_attest.return_value = batch_result
        client.attest.side_effect = Exception("API down")
        mock_get_client.return_value = client

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

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            mp = str(tmp_path / "manifest.db")
            result = runner.invoke(main, [
                "scan", str(scan_dir), "--manifest", mp,
                "--attest-manifest", "--json",
            ])
            assert result.exit_code == 0
            data = json.loads(result.output)
            assert "manifest_attestation_id" not in data
            assert data["attested"] >= 1
        finally:
            logging.disable(logging.NOTSET)

    @patch("certisigma_census.cli._get_client")
    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_attest_manifest_source_label(self, _mock_hash, mock_get_client, tmp_path):
        """--attest-manifest must call client.attest with source='census-manifest'."""
        client = MagicMock()
        batch_result = MagicMock()
        batch_result.attestations = [
            {"id": "att-1", "hash_hex": _deterministic_hash(str(tmp_path / "data/a.txt")), "timestamp": "2026-01-01"},
        ]
        client.batch_attest.return_value = batch_result

        attest_result = MagicMock()
        attest_result.id = "manifest-att-1"
        client.attest.return_value = attest_result
        mock_get_client.return_value = client

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

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            mp = str(tmp_path / "manifest.db")
            runner.invoke(main, [
                "scan", str(scan_dir), "--manifest", mp, "--attest-manifest",
            ])
            client.attest.assert_called_once()
            _, kwargs = client.attest.call_args
            assert kwargs.get("source") == "census-manifest"
        finally:
            logging.disable(logging.NOTSET)


# ─── JSONL output tests ──────────────────────────────────────────────────

class TestCompareJsonl:
    """Tests for compare --format jsonl."""

    @patch("certisigma_census.cli._get_client")
    @patch("certisigma_census.compare.hash_file", return_value="ab" * 32)
    def test_compare_jsonl_format(self, _mock_hash, mock_get_client, tmp_path):
        """--format jsonl emits one JSON object per match + summary line."""
        client = MagicMock()
        verify_result = MagicMock()
        verify_result.count = 1
        verify_result.found = 1
        verify_result.not_found = 0
        verify_result.results = [{"hash_hex": "ab" * 32, "exists": True, "id": "att-1", "level": "T0", "source": "test"}]
        client.batch_verify.return_value = verify_result
        mock_get_client.return_value = client

        suspect_dir = tmp_path / "suspect"
        suspect_dir.mkdir()
        (suspect_dir / "leak.txt").write_bytes(b"secret")

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            result = runner.invoke(main, [
                "compare", str(suspect_dir), "--format", "jsonl",
            ])
            jsonl_lines = [line for line in result.output.strip().split("\n") if line.startswith("{")]
            lines = [json.loads(line) for line in jsonl_lines]
            assert len(lines) >= 2
            assert lines[-1]["_summary"] is True
            assert lines[-1]["count"] >= 1
            assert "census_version" in lines[-1]
            assert "total_suspect" in lines[-1]
            assert "matched" in lines[-1]
            assert "not_matched" in lines[-1]
            assert "errors" in lines[-1]
            assert lines[0]["hash_hex"] == "ab" * 32
        finally:
            logging.disable(logging.NOTSET)

    @patch("certisigma_census.cli._get_client")
    @patch("certisigma_census.compare.hash_file", return_value="cd" * 32)
    def test_compare_jsonl_no_matches(self, _mock_hash, mock_get_client, tmp_path):
        """JSONL with zero matches emits only the summary line."""
        client = MagicMock()
        verify_result = MagicMock()
        verify_result.count = 1
        verify_result.found = 0
        verify_result.not_found = 1
        verify_result.results = [{"hash_hex": "cd" * 32, "exists": False}]
        client.batch_verify.return_value = verify_result
        mock_get_client.return_value = client

        suspect_dir = tmp_path / "suspect"
        suspect_dir.mkdir()
        (suspect_dir / "clean.txt").write_bytes(b"hello")

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            result = runner.invoke(main, [
                "compare", str(suspect_dir), "--format", "jsonl",
            ])
            jsonl_lines = [line for line in result.output.strip().split("\n") if line.startswith("{")]
            lines = [json.loads(line) for line in jsonl_lines]
            assert len(lines) == 1
            assert lines[0]["_summary"] is True
            assert lines[0]["count"] == 0
        finally:
            logging.disable(logging.NOTSET)


class TestOnMatchHook:
    """Tests for compare --on-match."""

    @patch("certisigma_census.cli._run_on_match")
    @patch("certisigma_census.cli._get_client")
    @patch("certisigma_census.compare.hash_file", return_value="ab" * 32)
    def test_on_match_called_when_matches(self, _mock_hash, mock_get_client, mock_run, tmp_path):
        """--on-match command is executed when matches are found."""
        client = MagicMock()
        verify_result = MagicMock()
        verify_result.count = 1
        verify_result.found = 1
        verify_result.not_found = 0
        verify_result.results = [{"hash_hex": "ab" * 32, "exists": True, "id": "att-1", "level": "T0", "source": "test"}]
        client.batch_verify.return_value = verify_result
        mock_get_client.return_value = client

        suspect_dir = tmp_path / "suspect"
        suspect_dir.mkdir()
        (suspect_dir / "leak.txt").write_bytes(b"secret")

        runner = CliRunner()
        runner.invoke(main, [
            "compare", str(suspect_dir), "--on-match", "echo alert",
        ])
        mock_run.assert_called_once()
        call_args = mock_run.call_args
        assert call_args[0][0] == "echo alert"
        assert call_args[0][1]["matched"] >= 1

    @patch("certisigma_census.cli._run_on_match")
    @patch("certisigma_census.cli._get_client")
    @patch("certisigma_census.compare.hash_file", return_value="cd" * 32)
    def test_on_match_not_called_without_matches(self, _mock_hash, mock_get_client, mock_run, tmp_path):
        """--on-match command is NOT executed when there are no matches."""
        client = MagicMock()
        verify_result = MagicMock()
        verify_result.count = 1
        verify_result.found = 0
        verify_result.not_found = 1
        verify_result.results = [{"hash_hex": "cd" * 32, "exists": False}]
        client.batch_verify.return_value = verify_result
        mock_get_client.return_value = client

        suspect_dir = tmp_path / "suspect"
        suspect_dir.mkdir()
        (suspect_dir / "clean.txt").write_bytes(b"hello")

        runner = CliRunner()
        runner.invoke(main, [
            "compare", str(suspect_dir), "--on-match", "echo alert", "--exit-zero",
        ])
        mock_run.assert_not_called()


class TestIntegrityJsonl:
    """Tests for integrity --format jsonl."""

    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_integrity_jsonl_reports_changes(self, _mock_hash, tmp_path):
        """--format jsonl emits one line per discrepancy."""
        scan_dir = tmp_path / "data"
        scan_dir.mkdir()
        (scan_dir / "a.txt").write_bytes(b"aaa")
        (scan_dir / "b.txt").write_bytes(b"bbb")

        m = Manifest(root_dir=str(scan_dir))
        m.add(ManifestEntry(
            hash_hex=_deterministic_hash(str(scan_dir / "a.txt")),
            path=str(scan_dir / "a.txt"),
            size=3, mtime=0.0,
        ))
        m.add(ManifestEntry(
            hash_hex="00" * 32,
            path=str(scan_dir / "deleted.txt"),
            size=10, mtime=0.0,
        ))
        mp = tmp_path / "manifest.db"
        m.save(mp)
        m.close()

        runner = CliRunner()
        result = runner.invoke(main, ["integrity", str(mp), "--format", "jsonl"])
        lines = [json.loads(line) for line in result.output.strip().split("\n")]
        summary = lines[-1]
        assert summary["_summary"] is True
        assert summary["count"] >= 1
        assert "total_in_manifest" in summary
        assert "unchanged" in summary
        statuses = {line.get("status") for line in lines[:-1]}
        assert statuses & {"missing", "new"}


class TestDiffJsonl:
    """Tests for diff --format jsonl."""

    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_diff_jsonl_format(self, _mock_hash, tmp_path):
        """--format jsonl emits one line per changed entry."""
        base = Manifest(root_dir=str(tmp_path / "base"))
        base.add(ManifestEntry(hash_hex="aa" * 32, path="/file1.txt", size=10, mtime=0.0))
        bp = tmp_path / "base.db"
        base.save(bp)
        base.close()

        target = Manifest(root_dir=str(tmp_path / "target"))
        target.add(ManifestEntry(hash_hex="bb" * 32, path="/file1.txt", size=20, mtime=0.0))
        target.add(ManifestEntry(hash_hex="cc" * 32, path="/file2.txt", size=5, mtime=0.0))
        tp = tmp_path / "target.db"
        target.save(tp)
        target.close()

        runner = CliRunner()
        result = runner.invoke(main, ["diff", str(bp), str(tp), "--format", "jsonl"])
        assert result.exit_code != 0 or True  # diff may exit non-zero
        lines = [json.loads(line) for line in result.output.strip().split("\n")]
        summary = lines[-1]
        assert summary["_summary"] is True
        assert summary["count"] >= 1
        assert "base_root" in summary
        assert "target_root" in summary
        assert "added" in summary
        assert "removed" in summary
        assert "modified" in summary
        assert "unchanged_count" in summary


class TestPhase132MetaCoverage:
    """Tests for _with_meta on commands enriched in Phase 13.2."""

    def test_status_json_has_meta(self, tmp_path):
        """census status --json includes census_version and elapsed_seconds."""
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="aa" * 32, path="/a.txt", size=1, mtime=0.0))
        mp = tmp_path / "manifest.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["census_version"] == __version__
        assert "elapsed_seconds" in data

    def test_diff_json_has_meta(self, tmp_path):
        """census diff --json includes census_version and elapsed_seconds."""
        for name in ("a.db", "b.db"):
            m = Manifest(root_dir=str(tmp_path))
            mp = tmp_path / name
            m.save(mp)
            m.close()

        runner = CliRunner()
        result = runner.invoke(main, ["diff", str(tmp_path / "a.db"), str(tmp_path / "b.db"), "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["census_version"] == __version__
        assert "elapsed_seconds" in data

    def test_merge_json_has_meta(self, tmp_path):
        """census merge --json includes census_version and elapsed_seconds."""
        for name in ("a.db", "b.db"):
            m = Manifest(root_dir=str(tmp_path))
            m.add(ManifestEntry(hash_hex=f"{name[0]}{name[0]}" * 32, path=f"/{name}", size=1, mtime=0.0))
            mp = tmp_path / name
            m.save(mp)
            m.close()

        runner = CliRunner()
        out = tmp_path / "merged.db"
        result = runner.invoke(main, [
            "merge", str(tmp_path / "a.db"), str(tmp_path / "b.db"), "-o", str(out), "--json",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["census_version"] == __version__
        assert "elapsed_seconds" in data


class TestPhase161PropagationFixes:
    """Regression tests for Phase 16.1 propagation fixes."""

    def test_write_compare_report_default_str(self, tmp_path):
        """_write_compare_report uses default=str — no TypeError on edge types."""
        from certisigma_census.cli import _write_compare_report
        from certisigma_census.compare import CompareReport

        report = CompareReport(total_suspect=1, matched=0, not_matched=1, errors=0)

        out = tmp_path / "report.json"
        _write_compare_report(report, out)
        assert out.exists()
        data = json.loads(out.read_text())
        assert isinstance(data, dict)
        assert data["total_suspect"] == 1

    def test_snapshot_resolve_override(self, tmp_path):
        """_resolve_snapshot_dir must resolve user-provided override paths."""
        from certisigma_census.snapshot import _resolve_snapshot_dir
        result = _resolve_snapshot_dir(tmp_path / "relative")
        assert result.is_absolute()

    def test_audit_log_resolve_override(self, tmp_path):
        """_resolve_log_path must resolve user-provided override paths."""
        from certisigma_census.audit_log import _resolve_log_path
        result = _resolve_log_path(tmp_path / "custom.jsonl")
        assert result.is_absolute()
