"""Tests for breach comparison module (compare.py).

All tests mock the CertiSigma SDK — no real API calls are made.
"""

from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from certisigma_census.compare import compare_directory, CompareReport, MatchResult
from certisigma_census.manifest import Manifest, ManifestEntry


FAKE_HASHES = {
    "report.pdf": "aa" * 32,
    "data.csv": "bb" * 32,
    "notes.txt": "cc" * 32,
    "schema.json": "dd" * 32,
    "image.png": "ee" * 32,
}


def _make_hash_side_effect(base_dir: Path):
    """Return a hash_file mock that maps known filenames to deterministic hashes."""
    def _hash_file(path_str: str) -> str:
        name = Path(path_str).name
        if name in FAKE_HASHES:
            return FAKE_HASHES[name]
        return "00" * 32
    return _hash_file


def _verify_result(hash_hex: str, exists: bool, **kwargs) -> dict:
    """Build a single batch_verify result item."""
    item = {"hash_hex": hash_hex, "exists": exists}
    if exists:
        item.setdefault("id", f"att_{hash_hex[:8]}")
        item.setdefault("timestamp", "2026-01-15T10:00:00Z")
        item.setdefault("level", "T1")
    item.update(kwargs)
    return item


@patch("certisigma_census.compare.hash_file")
def test_compare_all_matched(mock_hash, populated_dir, mock_client):
    """All suspect files exist in the registry."""
    mock_hash.side_effect = _make_hash_side_effect(populated_dir)

    all_hashes = list(FAKE_HASHES.values())
    mock_client.batch_verify.return_value = MagicMock(
        results=[_verify_result(h, exists=True) for h in all_hashes],
    )

    report = compare_directory(populated_dir, mock_client)

    assert report.total_suspect == 5
    assert report.matched == 5
    assert report.not_matched == 0
    assert report.errors == 0
    assert len(report.matches) == 5


@patch("certisigma_census.compare.hash_file")
def test_compare_none_matched(mock_hash, populated_dir, mock_client):
    """No suspect files match the registry."""
    mock_hash.side_effect = _make_hash_side_effect(populated_dir)

    all_hashes = list(FAKE_HASHES.values())
    mock_client.batch_verify.return_value = MagicMock(
        results=[_verify_result(h, exists=False) for h in all_hashes],
    )

    report = compare_directory(populated_dir, mock_client)

    assert report.total_suspect == 5
    assert report.matched == 0
    assert report.not_matched == 5
    assert report.errors == 0
    assert len(report.matches) == 0


@patch("certisigma_census.compare.hash_file")
def test_compare_mixed_results(mock_hash, populated_dir, mock_client):
    """Some files match, some don't."""
    mock_hash.side_effect = _make_hash_side_effect(populated_dir)

    hashes = list(FAKE_HASHES.values())
    results = [
        _verify_result(hashes[0], exists=True),
        _verify_result(hashes[1], exists=True),
        _verify_result(hashes[2], exists=False),
        _verify_result(hashes[3], exists=False),
        _verify_result(hashes[4], exists=False),
    ]
    mock_client.batch_verify.return_value = MagicMock(results=results)

    report = compare_directory(populated_dir, mock_client)

    assert report.matched == 2
    assert report.not_matched == 3
    assert report.errors == 0


@patch("certisigma_census.compare.hash_file")
def test_compare_with_manifest_crossref(mock_hash, populated_dir, mock_client):
    """Matches are enriched with inventory paths from local manifest."""
    mock_hash.side_effect = _make_hash_side_effect(populated_dir)

    target_hash = FAKE_HASHES["report.pdf"]
    manifest = Manifest(root_dir="/original/path")
    try:
        manifest.add(ManifestEntry(
            hash_hex=target_hash,
            path="originals/quarterly-report.pdf",
            size=5000,
            mtime=1.0,
        ))

        hashes = list(FAKE_HASHES.values())
        results = [
            _verify_result(h, exists=(h == target_hash))
            for h in hashes
        ]
        mock_client.batch_verify.return_value = MagicMock(results=results)

        report = compare_directory(populated_dir, mock_client, local_manifest=manifest)

        assert report.matched == 1
        match = report.matches[0]
        assert match.inventory_path == "originals/quarterly-report.pdf"
    finally:
        manifest.close()


@patch("certisigma_census.compare.hash_file")
def test_compare_batch_chunking(mock_hash, tmp_path, mock_client):
    """More than 100 files triggers multiple batch_verify calls."""
    for i in range(150):
        (tmp_path / f"file_{i:04d}.txt").write_bytes(f"content-{i}".encode())

    call_count = 0
    def _hash(path_str):
        return f"{abs(hash(path_str)) % (16**64):064x}"
    mock_hash.side_effect = _hash

    def _batch_verify(hashes, **kwargs):
        nonlocal call_count
        call_count += 1
        return MagicMock(results=[_verify_result(h, exists=False) for h in hashes])
    mock_client.batch_verify.side_effect = _batch_verify

    report = compare_directory(tmp_path, mock_client)

    assert report.total_suspect == 150
    assert call_count == 2  # 100 + 50


@patch("certisigma_census.compare.hash_file")
def test_compare_handles_verify_error(mock_hash, populated_dir, mock_client):
    """Exception during batch_verify increments error count."""
    mock_hash.side_effect = _make_hash_side_effect(populated_dir)
    mock_client.batch_verify.side_effect = Exception("API timeout")

    report = compare_directory(populated_dir, mock_client)

    assert report.total_suspect == 5
    assert report.errors == 5
    assert report.matched == 0


@patch("certisigma_census.compare.hash_file")
def test_compare_handles_unreadable_file(mock_hash, populated_dir, mock_client):
    """OSError on hash_file is handled gracefully."""
    def _hash_or_fail(path_str: str) -> str:
        if Path(path_str).name == "image.png":
            raise OSError("Permission denied")
        return _make_hash_side_effect(populated_dir)(path_str)

    mock_hash.side_effect = _hash_or_fail
    mock_client.batch_verify.return_value = MagicMock(
        results=[_verify_result(h, exists=False) for h in list(FAKE_HASHES.values())[:4]],
    )

    report = compare_directory(populated_dir, mock_client)

    assert report.total_suspect == 4
    assert report.errors == 1


@patch("certisigma_census.compare.hash_file")
def test_compare_empty_directory(mock_hash, tmp_path, mock_client):
    """Empty directory yields a clean report with zero counts."""
    report = compare_directory(tmp_path, mock_client)

    assert report.total_suspect == 0
    assert report.matched == 0
    assert report.not_matched == 0
    assert report.errors == 0
    mock_client.batch_verify.assert_not_called()


# ─── Filter propagation ──────────────────────────────────────────────────


@patch("certisigma_census.compare.hash_file")
def test_compare_include_glob(mock_hash, populated_dir, mock_client):
    """include_globs restricts which suspect files are hashed."""
    mock_hash.side_effect = _make_hash_side_effect(populated_dir)
    mock_client.batch_verify.return_value = MagicMock(
        results=[_verify_result(FAKE_HASHES["report.pdf"], exists=False)],
    )

    report = compare_directory(
        populated_dir, mock_client, include_globs=["*.pdf"],
    )
    assert report.total_suspect == 1


@patch("certisigma_census.compare.hash_file")
def test_compare_exclude_glob(mock_hash, populated_dir, mock_client):
    """exclude_globs removes matching files from comparison."""
    mock_hash.side_effect = _make_hash_side_effect(populated_dir)
    mock_client.batch_verify.return_value = MagicMock(
        results=[_verify_result(h, exists=False) for h in list(FAKE_HASHES.values())[:4]],
    )

    report = compare_directory(
        populated_dir, mock_client, exclude_globs=["*.png"],
    )
    assert report.total_suspect == 4
    hashed_paths = [Path(c.args[0]).name for c in mock_hash.call_args_list]
    assert "image.png" not in hashed_paths


@patch("certisigma_census.compare.hash_file")
def test_compare_size_filters(mock_hash, tmp_path, mock_client):
    """min_file_size and max_file_size restrict which files are compared."""
    (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)

    mock_hash.return_value = "ff" * 32
    mock_client.batch_verify.return_value = MagicMock(
        results=[_verify_result("ff" * 32, exists=False)],
    )

    report = compare_directory(
        tmp_path, mock_client, min_file_size=100, max_file_size=2000,
    )
    assert report.total_suspect == 1


# ─── Parallel hashing ─────────────────────────────────────────────────────


def test_compare_parallel_produces_same_results(tmp_path, mock_client):
    """Parallel compare (workers=2) yields the same hashes as sequential."""
    for i in range(5):
        (tmp_path / f"file_{i}.txt").write_text(f"content {i}")

    mock_client.batch_verify.return_value = MagicMock(
        results=[{"hash_hex": "x", "exists": False} for _ in range(5)],
    )

    serial = compare_directory(tmp_path, mock_client, workers=1)
    parallel = compare_directory(tmp_path, mock_client, workers=2)

    assert serial.total_suspect == parallel.total_suspect
    assert serial.matched == parallel.matched
