"""Tests for Scan Cache & Incremental Scanning."""

from __future__ import annotations

import json
import time
from pathlib import Path

import pytest

from mcpaudit.scan_cache import (
    CacheEntry,
    CacheStats,
    IncrementalResult,
    IncrementalScanner,
    ScanCache,
    _cache_key,
    _file_hash,
    format_cache_stats_json,
    format_cache_stats_text,
    format_incremental_text,
)


# --- _file_hash ---


class TestFileHash:
    def test_consistent_hash(self, tmp_path):
        f = tmp_path / "test.py"
        f.write_text("print('hello')")
        h1 = _file_hash(f)
        h2 = _file_hash(f)
        assert h1 == h2

    def test_different_content_different_hash(self, tmp_path):
        f1 = tmp_path / "a.py"
        f2 = tmp_path / "b.py"
        f1.write_text("content_a")
        f2.write_text("content_b")
        assert _file_hash(f1) != _file_hash(f2)

    def test_hash_is_hex_string(self, tmp_path):
        f = tmp_path / "hex.py"
        f.write_text("test")
        h = _file_hash(f)
        assert len(h) == 64  # SHA-256 hex
        assert all(c in "0123456789abcdef" for c in h)

    def test_empty_file(self, tmp_path):
        f = tmp_path / "empty.py"
        f.write_text("")
        h = _file_hash(f)
        assert len(h) == 64


# --- _cache_key ---


class TestCacheKey:
    def test_produces_safe_string(self, tmp_path):
        p = tmp_path / "server.py"
        p.write_text("")
        key = _cache_key(p)
        assert "/" not in key
        assert "\\" not in key

    def test_different_paths_different_keys(self, tmp_path):
        p1 = tmp_path / "a.py"
        p2 = tmp_path / "b.py"
        p1.write_text("")
        p2.write_text("")
        assert _cache_key(p1) != _cache_key(p2)


# --- CacheEntry ---


class TestCacheEntry:
    def test_creation(self):
        entry = CacheEntry(
            filepath="/test/file.py",
            file_hash="abc123",
            scan_result={"findings": []},
            timestamp=1000.0,
            version="1.0.0",
        )
        assert entry.filepath == "/test/file.py"
        assert entry.file_hash == "abc123"

    def test_to_dict(self):
        entry = CacheEntry(
            filepath="/f.py", file_hash="h1",
            scan_result={"key": "val"}, timestamp=42.0,
        )
        d = entry.to_dict()
        assert d["filepath"] == "/f.py"
        assert d["file_hash"] == "h1"
        assert d["scan_result"] == {"key": "val"}

    def test_from_dict(self):
        data = {
            "filepath": "/x.py",
            "file_hash": "hash",
            "scan_result": {"findings": [1, 2]},
            "timestamp": 100.0,
            "version": "2.0",
        }
        entry = CacheEntry.from_dict(data)
        assert entry.filepath == "/x.py"
        assert entry.version == "2.0"

    def test_roundtrip(self):
        entry = CacheEntry("/a.py", "h", {"r": 1}, 50.0, "v1")
        restored = CacheEntry.from_dict(entry.to_dict())
        assert restored.filepath == entry.filepath
        assert restored.file_hash == entry.file_hash
        assert restored.version == entry.version


# --- CacheStats ---


class TestCacheStats:
    def test_defaults(self):
        stats = CacheStats()
        assert stats.total_entries == 0
        assert stats.cache_hits == 0
        assert stats.cache_misses == 0

    def test_to_dict(self):
        stats = CacheStats(total_entries=5, cache_hits=3, cache_misses=2)
        d = stats.to_dict()
        assert d["total_entries"] == 5
        assert d["cache_hits"] == 3


# --- ScanCache ---


class TestScanCache:
    def test_init_default(self):
        cache = ScanCache()
        assert "cache" in str(cache.cache_dir)

    def test_init_custom_dir(self, tmp_path):
        cache_dir = tmp_path / "my_cache"
        cache = ScanCache(cache_dir=cache_dir)
        assert cache.cache_dir == cache_dir

    def test_save_and_get(self, tmp_path):
        cache_dir = tmp_path / "cache"
        source = tmp_path / "source.py"
        source.write_text("print('hello')")

        cache = ScanCache(cache_dir=cache_dir)
        result = {"findings": [{"rule_id": "CMD-001"}]}
        cache.save(source, result)

        cached = cache.get(source)
        assert cached is not None
        assert cached["findings"][0]["rule_id"] == "CMD-001"

    def test_cache_miss_no_entry(self, tmp_path):
        cache = ScanCache(cache_dir=tmp_path / "cache")
        source = tmp_path / "missing.py"
        source.write_text("")
        assert cache.get(source) is None

    def test_cache_miss_file_changed(self, tmp_path):
        cache_dir = tmp_path / "cache"
        source = tmp_path / "changing.py"
        source.write_text("version1")

        cache = ScanCache(cache_dir=cache_dir)
        cache.save(source, {"v": 1})

        # Modify file
        source.write_text("version2")
        assert cache.get(source) is None

    def test_cache_miss_version_mismatch(self, tmp_path):
        cache_dir = tmp_path / "cache"
        source = tmp_path / "versioned.py"
        source.write_text("test")

        cache_v1 = ScanCache(cache_dir=cache_dir, version="1.0")
        cache_v1.save(source, {"v": 1})

        cache_v2 = ScanCache(cache_dir=cache_dir, version="2.0")
        assert cache_v2.get(source) is None

    def test_invalidate(self, tmp_path):
        cache_dir = tmp_path / "cache"
        source = tmp_path / "to_invalidate.py"
        source.write_text("test")

        cache = ScanCache(cache_dir=cache_dir)
        cache.save(source, {"data": 1})
        assert cache.get(source) is not None

        result = cache.invalidate(source)
        assert result is True
        assert cache.get(source) is None

    def test_invalidate_nonexistent(self, tmp_path):
        cache = ScanCache(cache_dir=tmp_path / "cache")
        result = cache.invalidate(tmp_path / "nonexistent.py")
        assert result is False

    def test_clear(self, tmp_path):
        cache_dir = tmp_path / "cache"
        cache = ScanCache(cache_dir=cache_dir)

        for i in range(5):
            f = tmp_path / f"file{i}.py"
            f.write_text(f"content{i}")
            cache.save(f, {"n": i})

        count = cache.clear()
        assert count == 5

    def test_clear_empty(self, tmp_path):
        cache = ScanCache(cache_dir=tmp_path / "empty_cache")
        assert cache.clear() == 0

    def test_stats(self, tmp_path):
        cache_dir = tmp_path / "cache"
        cache = ScanCache(cache_dir=cache_dir)

        f = tmp_path / "stat_test.py"
        f.write_text("test")
        cache.save(f, {"data": 1})

        stats = cache.stats()
        assert stats.total_entries == 1
        assert stats.cache_size_bytes > 0
        assert str(cache_dir) in stats.cache_dir

    def test_stats_empty(self, tmp_path):
        cache = ScanCache(cache_dir=tmp_path / "empty_stats")
        stats = cache.stats()
        assert stats.total_entries == 0
        assert stats.cache_size_bytes == 0

    def test_has_entry(self, tmp_path):
        cache_dir = tmp_path / "cache"
        cache = ScanCache(cache_dir=cache_dir)

        f = tmp_path / "has_entry.py"
        f.write_text("test")
        assert cache.has_entry(f) is False

        cache.save(f, {"data": 1})
        assert cache.has_entry(f) is True

    def test_list_entries(self, tmp_path):
        cache_dir = tmp_path / "cache"
        cache = ScanCache(cache_dir=cache_dir)

        for i in range(3):
            f = tmp_path / f"list{i}.py"
            f.write_text(f"content{i}")
            cache.save(f, {"n": i})

        entries = cache.list_entries()
        assert len(entries) == 3

    def test_list_entries_empty(self, tmp_path):
        cache = ScanCache(cache_dir=tmp_path / "empty_list")
        assert cache.list_entries() == []

    def test_hits_and_misses(self, tmp_path):
        cache_dir = tmp_path / "cache"
        cache = ScanCache(cache_dir=cache_dir)

        f = tmp_path / "hm.py"
        f.write_text("test")
        cache.save(f, {"data": 1})

        cache.get(f)  # hit
        cache.get(f)  # hit

        f2 = tmp_path / "missing.py"
        f2.write_text("")
        cache.get(f2)  # miss

        stats = cache.stats()
        assert stats.cache_hits == 2
        assert stats.cache_misses == 1

    def test_corrupted_cache_file(self, tmp_path):
        cache_dir = tmp_path / "cache"
        cache_dir.mkdir(parents=True)
        cache = ScanCache(cache_dir=cache_dir)

        f = tmp_path / "corrupted.py"
        f.write_text("test")

        # Write corrupted cache file
        entry_path = cache._entry_path(f)
        entry_path.write_text("not json{{{")

        assert cache.get(f) is None

    def test_file_deleted_after_cache(self, tmp_path):
        cache_dir = tmp_path / "cache"
        cache = ScanCache(cache_dir=cache_dir)

        f = tmp_path / "will_delete.py"
        f.write_text("test")
        cache.save(f, {"data": 1})
        f.unlink()

        assert cache.get(f) is None

    def test_save_with_precomputed_hash(self, tmp_path):
        cache_dir = tmp_path / "cache"
        cache = ScanCache(cache_dir=cache_dir)

        f = tmp_path / "precomputed.py"
        f.write_text("test")
        h = _file_hash(f)

        cache.save(f, {"data": 1}, file_hash=h)
        assert cache.get(f) is not None


# --- IncrementalResult ---


class TestIncrementalResult:
    def test_defaults(self):
        r = IncrementalResult()
        assert r.cached_count == 0
        assert r.scanned_count == 0
        assert r.cache_hit_rate == 0.0

    def test_cache_hit_rate(self):
        r = IncrementalResult(
            cached_count=7, scanned_count=3, total_files=10,
        )
        assert r.cache_hit_rate == 70.0

    def test_to_dict(self):
        r = IncrementalResult(
            cached_count=2, scanned_count=1, total_files=3,
            results=[{"file": "a.py"}],
        )
        d = r.to_dict()
        assert d["total_files"] == 3
        assert d["cache_hit_rate"] == 66.7


# --- IncrementalScanner ---


class TestIncrementalScanner:
    def _mock_scan(self, filepath: Path) -> dict:
        return {
            "file": str(filepath),
            "findings": [{"rule_id": "TEST-001"}],
            "lines_scanned": 10,
        }

    def test_scan_file_fresh(self, tmp_path):
        cache_dir = tmp_path / "cache"
        f = tmp_path / "fresh.py"
        f.write_text("test")

        scanner = IncrementalScanner(
            scan_fn=self._mock_scan,
            cache_dir=cache_dir,
        )
        result = scanner.scan_file(f)
        assert result["file"] == str(f)
        assert len(result["findings"]) == 1

    def test_scan_file_cached(self, tmp_path):
        cache_dir = tmp_path / "cache"
        f = tmp_path / "cached.py"
        f.write_text("test")

        call_count = 0

        def counting_scan(fp):
            nonlocal call_count
            call_count += 1
            return {"file": str(fp), "findings": [], "lines_scanned": 5}

        scanner = IncrementalScanner(
            scan_fn=counting_scan,
            cache_dir=cache_dir,
        )

        scanner.scan_file(f)  # First scan
        scanner.scan_file(f)  # Should use cache

        assert call_count == 1  # Only scanned once

    def test_scan_files_mixed(self, tmp_path):
        cache_dir = tmp_path / "cache"
        f1 = tmp_path / "a.py"
        f2 = tmp_path / "b.py"
        f1.write_text("a")
        f2.write_text("b")

        scanner = IncrementalScanner(
            scan_fn=self._mock_scan,
            cache_dir=cache_dir,
        )

        # Pre-cache f1
        scanner.scan_file(f1)

        # Scan both
        result = scanner.scan_files([f1, f2])
        assert result.total_files == 2
        assert result.cached_count == 1
        assert result.scanned_count == 1

    def test_scan_files_all_cached(self, tmp_path):
        cache_dir = tmp_path / "cache"
        files = []
        for i in range(3):
            f = tmp_path / f"file{i}.py"
            f.write_text(f"content{i}")
            files.append(f)

        scanner = IncrementalScanner(
            scan_fn=self._mock_scan,
            cache_dir=cache_dir,
        )

        # Pre-cache all
        for f in files:
            scanner.scan_file(f)

        result = scanner.scan_files(files)
        assert result.cached_count == 3
        assert result.scanned_count == 0
        assert result.cache_hit_rate == 100.0

    def test_scan_files_nonexistent_skipped(self, tmp_path):
        cache_dir = tmp_path / "cache"
        scanner = IncrementalScanner(
            scan_fn=self._mock_scan,
            cache_dir=cache_dir,
        )
        result = scanner.scan_files([tmp_path / "nonexistent.py"])
        assert result.total_files == 0

    def test_default_scan_fn(self, tmp_path):
        cache_dir = tmp_path / "cache"
        f = tmp_path / "default.py"
        f.write_text("test")

        scanner = IncrementalScanner(cache_dir=cache_dir)
        result = scanner.scan_file(f)
        assert result["file"] == str(f)
        assert result["findings"] == []

    def test_cache_property(self, tmp_path):
        cache = ScanCache(cache_dir=tmp_path / "cache")
        scanner = IncrementalScanner(cache=cache)
        assert scanner.cache is cache

    def test_scan_time_measured(self, tmp_path):
        cache_dir = tmp_path / "cache"
        f = tmp_path / "timed.py"
        f.write_text("test")

        scanner = IncrementalScanner(
            scan_fn=self._mock_scan,
            cache_dir=cache_dir,
        )
        result = scanner.scan_files([f])
        assert result.scan_time_ms >= 0


# --- Formatting ---


class TestFormatting:
    def test_format_stats_text(self):
        stats = CacheStats(
            total_entries=10,
            cache_hits=7,
            cache_misses=3,
            cache_size_bytes=2048,
            cache_dir="/tmp/cache",
        )
        text = format_cache_stats_text(stats)
        assert "Scan Cache Stats" in text
        assert "Total entries:   10" in text
        assert "2.0 KB" in text

    def test_format_stats_text_bytes(self):
        stats = CacheStats(cache_size_bytes=512)
        text = format_cache_stats_text(stats)
        assert "512 B" in text

    def test_format_stats_text_mb(self):
        stats = CacheStats(cache_size_bytes=2 * 1024 * 1024)
        text = format_cache_stats_text(stats)
        assert "2.0 MB" in text

    def test_format_stats_json(self):
        stats = CacheStats(total_entries=5, cache_hits=3)
        j = format_cache_stats_json(stats)
        data = json.loads(j)
        assert data["total_entries"] == 5
        assert data["cache_hits"] == 3

    def test_format_incremental_text(self):
        result = IncrementalResult(
            cached_count=3, scanned_count=2, total_files=5,
            scan_time_ms=42.5,
        )
        text = format_incremental_text(result)
        assert "5 files" in text
        assert "60.0%" in text
        assert "42.5ms" in text
