"""Bulk scale tests — Phase 11 features.

These tests were previously skipped, waiting for SDK v1.8.0 to expose
``client.scan()`` and ``client.get_bulk_stats()``.  Both are now
available; the skip markers have been removed.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
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 main


# ─── helpers ────────────────────────────────────────────────────────────────

@dataclass
class _FakeScanMatch:
    hash_hex: str
    first_seen: str | None
    source: str | None = None


@dataclass
class _FakeScanResult:
    scan_id: str
    scanned_at: str
    total_scanned: int
    matched: int
    unmatched: int
    match_rate: float
    matches: list[_FakeScanMatch] = field(default_factory=list)


@dataclass
class _FakeBulkStats:
    org_id_hash: str
    total_claims: int
    unique_hashes: int
    first_claim: str | None = None
    last_claim: str | None = None
    claims_by_month: dict[str, int] = field(default_factory=dict)


def _mock_client(scan_result=None, stats_result=None):
    client = MagicMock()
    if scan_result is not None:
        client.scan.return_value = scan_result
    if stats_result is not None:
        client.get_bulk_stats.return_value = stats_result
    return client


# ─── TestBulkScan ───────────────────────────────────────────────────────────

class TestBulkScan:
    """Tests for the ``census bulk-scan`` command."""

    def test_bulk_scan_no_matches(self, tmp_path: Path):
        f = tmp_path / "test.txt"
        f.write_text("hello")

        result_obj = _FakeScanResult(
            scan_id="sc_001", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=0, unmatched=1, match_rate=0.0,
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path), "--json"])
            assert res.exit_code == 0, res.output
            data = json.loads(res.output)
            assert data["matched_files"] == 0
            assert data["matches"] == []

    def test_bulk_scan_with_matches(self, tmp_path: Path):
        f = tmp_path / "secret.doc"
        f.write_bytes(b"classified content")

        from certisigma import hash_file
        h = hash_file(str(f))

        match = _FakeScanMatch(hash_hex=h, first_seen="2025-06-15T12:00:00Z", source="finance")
        result_obj = _FakeScanResult(
            scan_id="sc_002", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=1, unmatched=0, match_rate=1.0,
            matches=[match],
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path), "--json"])
            assert res.exit_code == 1, res.output
            data = json.loads(res.output)
            assert data["matched_files"] == 1
            assert data["matches"][0]["hash_hex"] == h
            assert data["matches"][0]["first_seen"] == "2025-06-15T12:00:00Z"

    def test_bulk_scan_human_output(self, tmp_path: Path):
        f = tmp_path / "leak.pdf"
        f.write_bytes(b"leaked data")

        from certisigma import hash_file
        h = hash_file(str(f))

        match = _FakeScanMatch(hash_hex=h, first_seen="2025-07-01T00:00:00Z")
        result_obj = _FakeScanResult(
            scan_id="sc_003", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=1, unmatched=0, match_rate=1.0,
            matches=[match],
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path)])
            assert res.exit_code == 1
            output = res.output.lower()
            assert "potential leak" in output or "matched" in output or "1" in output

    def test_bulk_scan_empty_dir(self, tmp_path: Path):
        result_obj = _FakeScanResult(
            scan_id="sc_004", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=0, matched=0, unmatched=0, match_rate=0.0,
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path), "--json"])
            assert res.exit_code == 0
            data = json.loads(res.output)
            assert data["total_scanned"] == 0

    def test_bulk_scan_manifest_cross_ref(self, tmp_path: Path):
        f = tmp_path / "file.txt"
        f.write_text("content")

        from certisigma import hash_file
        h = hash_file(str(f))

        match = _FakeScanMatch(hash_hex=h, first_seen="2025-08-01T00:00:00Z")
        result_obj = _FakeScanResult(
            scan_id="sc_005", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=1, unmatched=0, match_rate=1.0,
            matches=[match],
        )
        client = _mock_client(scan_result=result_obj)

        from certisigma_census.manifest import Manifest, ManifestEntry
        mf_path = tmp_path / "inv.db"
        mf = Manifest(str(tmp_path))
        mf.add(ManifestEntry(hash_hex=h, path="/original/file.txt", size=7, mtime=1700000000.0))
        mf.save(mf_path)
        mf.close()

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, [
                "bulk-scan", str(tmp_path),
                "--manifest", str(mf_path), "--json",
            ])
            assert res.exit_code == 1
            data = json.loads(res.output)
            assert data["matches"][0]["inventory_path"] == "/original/file.txt"

    def test_bulk_scan_chunks_large_batch(self, tmp_path: Path):
        """Verify chunking logic when hash count exceeds SCAN_CHUNK_SIZE."""
        from certisigma_census.cli import SCAN_CHUNK_SIZE

        files = []
        for i in range(3):
            f = tmp_path / f"f{i}.bin"
            f.write_bytes(bytes([i]) * 16)
            files.append(f)

        result_obj = _FakeScanResult(
            scan_id="sc_chunk", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=2, matched=0, unmatched=2, match_rate=0.0,
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli.SCAN_CHUNK_SIZE", 2), \
             patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path), "--json"])
            assert res.exit_code == 0
            assert client.scan.call_count == 2

    def test_bulk_scan_api_error(self, tmp_path: Path):
        f = tmp_path / "test.txt"
        f.write_text("data")

        from certisigma import CertiSigmaError
        client = MagicMock()
        client.scan.side_effect = CertiSigmaError("auth failed")

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path), "--json"])
            assert res.exit_code == 1
            assert "auth failed" in res.output

    def test_bulk_scan_dry_run(self, tmp_path: Path):
        """--dry-run hashes files but makes no API call."""
        for i in range(3):
            (tmp_path / f"f{i}.txt").write_bytes(bytes([i]) * 32)

        client = MagicMock()
        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path), "--dry-run", "--json"])
            assert res.exit_code == 0
            data = json.loads(res.output)
            assert data["total_files"] == 3
            assert data["unique_hashes"] == 3
            assert data["chunks_needed"] == 1
            client.scan.assert_not_called()

    def test_bulk_scan_source_label(self, tmp_path: Path):
        """--source records label in audit and output."""
        f = tmp_path / "f.txt"
        f.write_text("data")

        result_obj = _FakeScanResult(
            scan_id="sc_src", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=0, unmatched=1, match_rate=0.0,
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit") as mock_audit:
            runner = CliRunner()
            res = runner.invoke(main, [
                "bulk-scan", str(tmp_path), "--json", "--source", "incident-42",
            ])
            assert res.exit_code == 0
            data = json.loads(res.output)
            assert data.get("source") == "incident-42"
            call_args = mock_audit.call_args
            assert call_args[0][1]["source"] == "incident-42"

    def test_bulk_scan_output_file(self, tmp_path: Path):
        """--output saves JSON results to file."""
        f = tmp_path / "data.bin"
        f.write_bytes(b"payload")

        result_obj = _FakeScanResult(
            scan_id="sc_out", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=0, unmatched=1, match_rate=0.0,
        )
        client = _mock_client(scan_result=result_obj)

        out_file = tmp_path / "results.json"
        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, [
                "bulk-scan", str(tmp_path), "--output", str(out_file),
            ])
            assert res.exit_code == 0
            assert out_file.exists()
            data = json.loads(out_file.read_text())
            assert "total_scanned" in data

    def test_bulk_scan_quiet(self, tmp_path: Path):
        """--quiet suppresses human output."""
        f = tmp_path / "f.txt"
        f.write_text("x")

        result_obj = _FakeScanResult(
            scan_id="sc_q", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=0, unmatched=1, match_rate=0.0,
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["-q", "bulk-scan", str(tmp_path)])
            assert res.exit_code == 0
            assert "hashing" not in res.output.lower()

    def test_bulk_scan_hash_errors(self, tmp_path: Path):
        """Unreadable files increment hash_errors without crashing."""
        f = tmp_path / "good.txt"
        f.write_text("ok")
        bad = tmp_path / "bad.bin"
        bad.write_bytes(b"x")
        bad.chmod(0o000)

        result_obj = _FakeScanResult(
            scan_id="sc_err", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=0, unmatched=1, match_rate=0.0,
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"), \
             patch("certisigma_census.cli.logger"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path), "--json"])
            assert res.exit_code == 0
            data = json.loads(res.output)
            assert data.get("hash_errors", 0) >= 1
            bad.chmod(0o644)

    def test_bulk_scan_duplicate_hashes(self, tmp_path: Path):
        """Two files with same content both appear in results."""
        for name in ("a.txt", "b.txt"):
            (tmp_path / name).write_text("identical content")

        from certisigma import hash_file
        h = hash_file(str(tmp_path / "a.txt"))

        match = _FakeScanMatch(hash_hex=h, first_seen="2025-01-01T00:00:00Z")
        result_obj = _FakeScanResult(
            scan_id="sc_dup", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=1, unmatched=0, match_rate=1.0,
            matches=[match],
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path), "--json"])
            assert res.exit_code == 1
            data = json.loads(res.output)
            paths = {m["suspect_path"] for m in data["matches"]}
            assert "a.txt" in paths
            assert "b.txt" in paths
            assert len(data["matches"]) == 2

    def test_bulk_scan_none_fields(self, tmp_path: Path):
        """Handle None first_seen and source gracefully."""
        f = tmp_path / "doc.pdf"
        f.write_bytes(b"secret")

        from certisigma import hash_file
        h = hash_file(str(f))

        match = _FakeScanMatch(hash_hex=h, first_seen=None, source=None)
        result_obj = _FakeScanResult(
            scan_id="sc_none", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=1, unmatched=0, match_rate=1.0,
            matches=[match],
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path)])
            assert res.exit_code == 1
            assert "doc.pdf" in res.output

    def test_bulk_scan_workers_clamped(self, tmp_path: Path):
        """Workers > MAX_WORKERS are clamped silently."""
        f = tmp_path / "f.txt"
        f.write_text("data")

        result_obj = _FakeScanResult(
            scan_id="sc_w", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=0, unmatched=1, match_rate=0.0,
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path), "--workers", "999", "--json"])
            assert res.exit_code == 0

    def test_bulk_scan_exit_zero_no_matches(self, tmp_path: Path):
        """--exit-zero returns 0 even when no matches."""
        (tmp_path / "f.txt").write_text("data")
        result_obj = _FakeScanResult(
            scan_id="sc_ez0", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=0, unmatched=1, match_rate=0.0,
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path), "--exit-zero", "--json"])
            assert res.exit_code == 0

    def test_bulk_scan_exit_zero_with_matches(self, tmp_path: Path):
        """--exit-zero returns 0 even when matches are found."""
        f = tmp_path / "leak.doc"
        f.write_bytes(b"secret data")

        from certisigma import hash_file
        h = hash_file(str(f))

        match = _FakeScanMatch(hash_hex=h, first_seen="2025-06-15T00:00:00Z")
        result_obj = _FakeScanResult(
            scan_id="sc_ez1", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=1, unmatched=0, match_rate=1.0,
            matches=[match],
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path), "--exit-zero", "--json"])
            assert res.exit_code == 0
            data = json.loads(res.output)
            assert data["matched_files"] == 1

    def test_bulk_scan_summary_hides_details(self, tmp_path: Path):
        """--summary shows counts but hides individual match details."""
        f = tmp_path / "report.xlsx"
        f.write_bytes(b"financial data")

        from certisigma import hash_file
        h = hash_file(str(f))

        match = _FakeScanMatch(hash_hex=h, first_seen="2025-09-01T00:00:00Z")
        result_obj = _FakeScanResult(
            scan_id="sc_sum", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=1, unmatched=0, match_rate=1.0,
            matches=[match],
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path), "--summary"])
            assert res.exit_code == 1
            assert "matched" in res.output.lower()
            assert "report.xlsx" not in res.output
            assert "potential leak" not in res.output.lower()

    def test_bulk_scan_summary_with_exit_zero(self, tmp_path: Path):
        """--summary --exit-zero: counts only, exit 0."""
        f = tmp_path / "data.bin"
        f.write_bytes(b"payload")

        from certisigma import hash_file
        h = hash_file(str(f))

        match = _FakeScanMatch(hash_hex=h, first_seen="2025-03-01T00:00:00Z")
        result_obj = _FakeScanResult(
            scan_id="sc_se", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=1, unmatched=0, match_rate=1.0,
            matches=[match],
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, [
                "bulk-scan", str(tmp_path), "--summary", "--exit-zero",
            ])
            assert res.exit_code == 0
            assert "data.bin" not in res.output

    def test_bulk_scan_summary_does_not_affect_json(self, tmp_path: Path):
        """--summary --json: JSON output still contains full matches array."""
        f = tmp_path / "leak.doc"
        f.write_bytes(b"classified")

        from certisigma import hash_file
        h = hash_file(str(f))

        match = _FakeScanMatch(hash_hex=h, first_seen="2025-05-01T00:00:00Z")
        result_obj = _FakeScanResult(
            scan_id="sc_sj", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=1, unmatched=0, match_rate=1.0,
            matches=[match],
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, [
                "bulk-scan", str(tmp_path), "--summary", "--json", "--exit-zero",
            ])
            assert res.exit_code == 0
            data = json.loads(res.output)
            assert len(data["matches"]) == 1
            assert data["matches"][0]["hash_hex"] == h

    def test_bulk_scan_exit_zero_does_not_suppress_api_error(self, tmp_path: Path):
        """--exit-zero does NOT suppress API errors (still exit 1)."""
        (tmp_path / "f.txt").write_text("data")

        from certisigma import CertiSigmaError
        client = MagicMock()
        client.scan.side_effect = CertiSigmaError("forbidden")

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, [
                "bulk-scan", str(tmp_path), "--exit-zero", "--json",
            ])
            assert res.exit_code == 1
            assert "forbidden" in res.output

    def test_bulk_scan_json_has_version_and_timing(self, tmp_path: Path):
        """JSON output includes census_version and elapsed_seconds."""
        (tmp_path / "f.txt").write_text("data")
        result_obj = _FakeScanResult(
            scan_id="sc_meta", scanned_at="2026-01-01T00:00:00Z",
            total_scanned=1, matched=0, unmatched=1, match_rate=0.0,
        )
        client = _mock_client(scan_result=result_obj)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["bulk-scan", str(tmp_path), "--json"])
            assert res.exit_code == 0
            data = json.loads(res.output)
            assert data["census_version"] == __version__
            assert "elapsed_seconds" in data
            assert isinstance(data["elapsed_seconds"], (int, float))

    def test_bulk_scan_dry_run_has_version(self, tmp_path: Path):
        """Dry-run JSON also includes census_version."""
        (tmp_path / "f.txt").write_text("data")
        with patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, [
                "bulk-scan", str(tmp_path), "--dry-run", "--json",
            ])
            assert res.exit_code == 0
            data = json.loads(res.output)
            assert data["census_version"] == __version__


# ─── TestStats ──────────────────────────────────────────────────────────────

class TestStats:
    """Tests for the ``census stats`` command."""

    def test_stats_json(self):
        stats = _FakeBulkStats(
            org_id_hash="abc123", total_claims=1500,
            unique_hashes=1200, first_claim="2025-01-15T10:00:00Z",
            last_claim="2026-03-01T14:00:00Z",
            claims_by_month={"2025-01": 100, "2025-02": 200},
        )
        client = _mock_client(stats_result=stats)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["stats", "--json"])
            assert res.exit_code == 0, res.output
            data = json.loads(res.output)
            assert data["total_claims"] == 1500
            assert data["unique_hashes"] == 1200
            assert data["claims_by_month"]["2025-01"] == 100

    def test_stats_human_output(self):
        stats = _FakeBulkStats(
            org_id_hash="abc123", total_claims=42,
            unique_hashes=30,
        )
        client = _mock_client(stats_result=stats)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["stats"])
            assert res.exit_code == 0
            assert "42" in res.output
            assert "30" in res.output

    def test_stats_api_error(self):
        from certisigma import CertiSigmaError
        client = MagicMock()
        client.get_bulk_stats.side_effect = CertiSigmaError("forbidden")

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["stats", "--json"])
            assert res.exit_code == 1
            data = json.loads(res.output)
            assert "error" in data

    def test_stats_monthly_breakdown(self):
        stats = _FakeBulkStats(
            org_id_hash="xyz789", total_claims=300,
            unique_hashes=250,
            claims_by_month={"2025-10": 50, "2025-11": 100, "2025-12": 150},
        )
        client = _mock_client(stats_result=stats)

        with patch("certisigma_census.cli._get_client", return_value=client), \
             patch("certisigma_census.cli._audit"):
            runner = CliRunner()
            res = runner.invoke(main, ["stats"])
            assert res.exit_code == 0
            assert "2025-10" in res.output
            assert "50" in res.output
