"""Scan Cache & Incremental Scanning.

File hash-based cache for scan results, enabling incremental re-scans
that only re-analyze files that have changed since the last scan.
Cache stored as JSON files in .mcpaudit/cache/.
Zero external dependencies.
"""

from __future__ import annotations

import hashlib
import json
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


DEFAULT_CACHE_DIR = ".mcpaudit/cache"


def _file_hash(filepath: Path) -> str:
    """Compute SHA-256 hash of a file's contents.

    Args:
        filepath: Path to the file to hash.

    Returns:
        Hex-encoded SHA-256 hash string.
    """
    h = hashlib.sha256()
    content = filepath.read_bytes()
    h.update(content)
    return h.hexdigest()


def _cache_key(filepath: Path) -> str:
    """Generate a cache key from a file path.

    Uses the resolved absolute path, replacing separators with underscores
    to create a flat filename suitable for cache storage.

    Args:
        filepath: Path to generate key for.

    Returns:
        Safe string usable as a filename.
    """
    resolved = str(filepath.resolve())
    safe = resolved.replace("/", "_").replace("\\", "_").replace(":", "_")
    if safe.startswith("_"):
        safe = safe[1:]
    return safe


@dataclass
class CacheEntry:
    """A cached scan result for a single file."""

    filepath: str
    file_hash: str
    scan_result: dict[str, Any]
    timestamp: float = 0.0
    version: str = ""

    def to_dict(self) -> dict[str, Any]:
        """Serialize to dictionary."""
        return {
            "filepath": self.filepath,
            "file_hash": self.file_hash,
            "scan_result": self.scan_result,
            "timestamp": self.timestamp,
            "version": self.version,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> CacheEntry:
        """Deserialize from dictionary."""
        return cls(
            filepath=data["filepath"],
            file_hash=data["file_hash"],
            scan_result=data.get("scan_result", {}),
            timestamp=data.get("timestamp", 0.0),
            version=data.get("version", ""),
        )


@dataclass
class CacheStats:
    """Statistics about the scan cache."""

    total_entries: int = 0
    cache_hits: int = 0
    cache_misses: int = 0
    cache_size_bytes: int = 0
    cache_dir: str = ""
    oldest_entry: float = 0.0
    newest_entry: float = 0.0

    def to_dict(self) -> dict[str, Any]:
        """Serialize to dictionary."""
        return {
            "total_entries": self.total_entries,
            "cache_hits": self.cache_hits,
            "cache_misses": self.cache_misses,
            "cache_size_bytes": self.cache_size_bytes,
            "cache_dir": self.cache_dir,
            "oldest_entry": self.oldest_entry,
            "newest_entry": self.newest_entry,
        }


class ScanCache:
    """File-based scan result cache.

    Stores scan results keyed by file path and content hash.
    Only re-scans files whose content has changed.
    """

    def __init__(
        self,
        cache_dir: str | Path | None = None,
        version: str = "",
    ) -> None:
        """Initialize the scan cache.

        Args:
            cache_dir: Directory for cache files. Defaults to .mcpaudit/cache/.
            version: Scanner version string for cache invalidation.
        """
        if cache_dir is None:
            self._cache_dir = Path(DEFAULT_CACHE_DIR)
        else:
            self._cache_dir = Path(cache_dir)
        self._version = version
        self._hits = 0
        self._misses = 0

    @property
    def cache_dir(self) -> Path:
        """Return the cache directory path."""
        return self._cache_dir

    def _ensure_dir(self) -> None:
        """Create cache directory if it doesn't exist."""
        self._cache_dir.mkdir(parents=True, exist_ok=True)

    def _entry_path(self, filepath: Path) -> Path:
        """Get the cache file path for a source file."""
        key = _cache_key(filepath)
        return self._cache_dir / f"{key}.json"

    def save(
        self,
        filepath: Path,
        result: dict[str, Any],
        file_hash: str | None = None,
    ) -> None:
        """Save a scan result to the cache.

        Args:
            filepath: Path of the scanned file.
            result: Scan result dictionary.
            file_hash: Optional pre-computed hash. Computed if not given.
        """
        self._ensure_dir()
        if file_hash is None:
            file_hash = _file_hash(filepath)

        entry = CacheEntry(
            filepath=str(filepath.resolve()),
            file_hash=file_hash,
            scan_result=result,
            timestamp=time.time(),
            version=self._version,
        )

        entry_path = self._entry_path(filepath)
        entry_path.write_text(
            json.dumps(entry.to_dict(), indent=2),
            encoding="utf-8",
        )

    def get(self, filepath: Path) -> dict[str, Any] | None:
        """Get a cached scan result if the file hasn't changed.

        Args:
            filepath: Path of the file to look up.

        Returns:
            Cached scan result dict, or None if cache miss.
        """
        entry_path = self._entry_path(filepath)
        if not entry_path.exists():
            self._misses += 1
            return None

        try:
            data = json.loads(entry_path.read_text(encoding="utf-8"))
            entry = CacheEntry.from_dict(data)
        except (json.JSONDecodeError, KeyError):
            self._misses += 1
            return None

        # Version mismatch = cache invalidation
        if self._version and entry.version and entry.version != self._version:
            self._misses += 1
            return None

        # Check if file has changed
        if not filepath.exists():
            self._misses += 1
            return None

        current_hash = _file_hash(filepath)
        if current_hash != entry.file_hash:
            self._misses += 1
            return None

        self._hits += 1
        return entry.scan_result

    def invalidate(self, filepath: Path) -> bool:
        """Remove a cached result for a specific file.

        Args:
            filepath: Path of the file to invalidate.

        Returns:
            True if an entry was removed, False if no entry existed.
        """
        entry_path = self._entry_path(filepath)
        if entry_path.exists():
            entry_path.unlink()
            return True
        return False

    def clear(self) -> int:
        """Remove all cached results.

        Returns:
            Number of entries removed.
        """
        if not self._cache_dir.exists():
            return 0
        count = 0
        for p in self._cache_dir.glob("*.json"):
            p.unlink()
            count += 1
        return count

    def stats(self) -> CacheStats:
        """Get cache statistics.

        Returns:
            CacheStats with counts and size information.
        """
        total = 0
        size = 0
        oldest = float("inf")
        newest = 0.0

        if self._cache_dir.exists():
            for p in self._cache_dir.glob("*.json"):
                total += 1
                size += p.stat().st_size
                try:
                    data = json.loads(p.read_text(encoding="utf-8"))
                    ts = data.get("timestamp", 0.0)
                    if ts < oldest:
                        oldest = ts
                    if ts > newest:
                        newest = ts
                except (json.JSONDecodeError, OSError):
                    pass

        if oldest == float("inf"):
            oldest = 0.0

        return CacheStats(
            total_entries=total,
            cache_hits=self._hits,
            cache_misses=self._misses,
            cache_size_bytes=size,
            cache_dir=str(self._cache_dir),
            oldest_entry=oldest,
            newest_entry=newest,
        )

    def has_entry(self, filepath: Path) -> bool:
        """Check if a cache entry exists for a file (ignoring validity)."""
        return self._entry_path(filepath).exists()

    def list_entries(self) -> list[str]:
        """List all cached file paths."""
        if not self._cache_dir.exists():
            return []
        entries = []
        for p in self._cache_dir.glob("*.json"):
            try:
                data = json.loads(p.read_text(encoding="utf-8"))
                entries.append(data.get("filepath", str(p)))
            except (json.JSONDecodeError, OSError):
                pass
        return sorted(entries)


@dataclass
class IncrementalResult:
    """Result of an incremental scan."""

    results: list[dict[str, Any]] = field(default_factory=list)
    cached_count: int = 0
    scanned_count: int = 0
    total_files: int = 0
    scan_time_ms: float = 0.0

    @property
    def cache_hit_rate(self) -> float:
        """Percentage of files served from cache."""
        if self.total_files == 0:
            return 0.0
        return round((self.cached_count / self.total_files) * 100, 1)

    def to_dict(self) -> dict[str, Any]:
        """Serialize to dictionary."""
        return {
            "total_files": self.total_files,
            "cached_count": self.cached_count,
            "scanned_count": self.scanned_count,
            "cache_hit_rate": self.cache_hit_rate,
            "scan_time_ms": round(self.scan_time_ms, 2),
            "results": self.results,
        }


class IncrementalScanner:
    """Scanner that uses cache for incremental scanning.

    Wraps a scan function and uses ScanCache to skip unchanged files.
    """

    def __init__(
        self,
        scan_fn: Any = None,
        cache: ScanCache | None = None,
        cache_dir: str | Path | None = None,
    ) -> None:
        """Initialize the incremental scanner.

        Args:
            scan_fn: Callable(Path) -> dict that scans a single file.
                     If None, a default scanner is used.
            cache: Optional pre-configured ScanCache.
            cache_dir: Optional cache directory (ignored if cache is provided).
        """
        self._scan_fn = scan_fn
        self._cache = cache or ScanCache(cache_dir=cache_dir)

    @property
    def cache(self) -> ScanCache:
        """Return the underlying cache."""
        return self._cache

    def scan_file(self, filepath: Path) -> dict[str, Any]:
        """Scan a single file with cache support.

        Args:
            filepath: Path to the file to scan.

        Returns:
            Scan result dict (from cache or fresh scan).
        """
        cached = self._cache.get(filepath)
        if cached is not None:
            return cached

        # Perform fresh scan
        if self._scan_fn is not None:
            result = self._scan_fn(filepath)
        else:
            result = self._default_scan(filepath)

        # Save to cache
        self._cache.save(filepath, result)
        return result

    def scan_files(self, filepaths: list[Path]) -> IncrementalResult:
        """Scan multiple files with cache support.

        Args:
            filepaths: List of file paths to scan.

        Returns:
            IncrementalResult with all results and cache statistics.
        """
        start = time.monotonic()
        results: list[dict[str, Any]] = []
        cached_count = 0
        scanned_count = 0

        for fp in filepaths:
            if not fp.exists():
                continue

            cached = self._cache.get(fp)
            if cached is not None:
                results.append(cached)
                cached_count += 1
            else:
                if self._scan_fn is not None:
                    result = self._scan_fn(fp)
                else:
                    result = self._default_scan(fp)
                results.append(result)
                self._cache.save(fp, result)
                scanned_count += 1

        elapsed = (time.monotonic() - start) * 1000

        return IncrementalResult(
            results=results,
            cached_count=cached_count,
            scanned_count=scanned_count,
            total_files=cached_count + scanned_count,
            scan_time_ms=elapsed,
        )

    def _default_scan(self, filepath: Path) -> dict[str, Any]:
        """Default scan function when none is provided."""
        return {
            "file": str(filepath),
            "findings": [],
            "lines_scanned": 0,
            "scan_time_ms": 0.0,
        }


def format_cache_stats_text(stats: CacheStats) -> str:
    """Format cache statistics as human-readable text."""
    lines: list[str] = []
    lines.append("=" * 40)
    lines.append("MCP Audit Scan Cache Stats")
    lines.append("=" * 40)
    lines.append(f"Cache directory: {stats.cache_dir}")
    lines.append(f"Total entries:   {stats.total_entries}")
    lines.append(f"Cache hits:      {stats.cache_hits}")
    lines.append(f"Cache misses:    {stats.cache_misses}")

    if stats.cache_size_bytes < 1024:
        size_str = f"{stats.cache_size_bytes} B"
    elif stats.cache_size_bytes < 1024 * 1024:
        size_str = f"{stats.cache_size_bytes / 1024:.1f} KB"
    else:
        size_str = f"{stats.cache_size_bytes / (1024 * 1024):.1f} MB"
    lines.append(f"Cache size:      {size_str}")

    return "\n".join(lines)


def format_cache_stats_json(stats: CacheStats) -> str:
    """Format cache statistics as JSON."""
    return json.dumps(stats.to_dict(), indent=2)


def format_incremental_text(result: IncrementalResult) -> str:
    """Format incremental scan result as text."""
    lines: list[str] = []
    lines.append(f"Incremental scan: {result.total_files} files")
    lines.append(f"  Cached:  {result.cached_count} ({result.cache_hit_rate}%)")
    lines.append(f"  Scanned: {result.scanned_count}")
    lines.append(f"  Time:    {result.scan_time_ms:.1f}ms")
    return "\n".join(lines)
