# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""Local file cache for evaluation results.

This module provides file-based caching for policy evaluation results,
enabling fast re-evaluation of unchanged files during pre-commit checks.

Cache Strategy:
- Key: SHA-256 hash of content + policies (truncated to 16 chars each)
- Location: .mergeguide/.cache/evaluations/
- TTL: 24 hours (configurable)
- Invalidation: Content change or policy change

Performance Target:
- < 2 seconds for typical commits (< 20 files)
- > 80% cache hit rate on incremental development

Example Usage:
    cache = FileCache()

    # Check cache before evaluation
    cached = cache.get("src/app.py", content, ["SEC-001", "QUAL-001"])
    if cached:
        # Use cached result
        passed, violations = cached.passed, cached.violations
    else:
        # Evaluate and cache result
        passed, violations = evaluate(content)
        cache.set("src/app.py", content, ["SEC-001", "QUAL-001"], passed, violations)

    # Get cache statistics
    stats = cache.stats()  # {"hits": 45, "misses": 5, "hit_ratio": 0.9}
"""

from __future__ import annotations

import hashlib
import json
import logging
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)


@dataclass
class CachedEvaluation:
    """Cached evaluation result for a file.

    Attributes:
        file_path: Path to the evaluated file.
        passed: Whether the evaluation passed (no errors).
        violations: List of violation dictionaries.
        cached_at: Unix timestamp when the result was cached.
        content_hash: Hash of the file content when cached.
        policies_hash: Hash of the policy IDs when cached.
    """

    file_path: str
    passed: bool
    violations: list[dict[str, Any]]
    cached_at: float
    content_hash: str
    policies_hash: str


class FileCache:
    """File-based cache for policy evaluation results.

    Caches evaluation results by content hash to enable fast re-evaluation
    of unchanged files. Results are stored as JSON files in a cache directory.

    Thread-safe for concurrent access.

    Args:
        cache_dir: Directory for cache files. Defaults to ".mergeguide/.cache/evaluations".
        max_age: Maximum age of cache entries in seconds. Defaults to 86400 (24 hours).

    Example:
        >>> cache = FileCache()
        >>> result = cache.get("app.py", "print('hello')", ["SEC-001"])
        >>> if result is None:
        ...     # Evaluate and cache
        ...     cache.set("app.py", "print('hello')", ["SEC-001"], True, [])
    """

    def __init__(
        self,
        cache_dir: str | Path = ".mergeguide/.cache/evaluations",
        max_age: int = 86400,
    ) -> None:
        """Initialize the file cache.

        Args:
            cache_dir: Directory for cache files.
            max_age: Maximum age of cache entries in seconds (default 24 hours).
        """
        self.cache_dir = Path(cache_dir)
        self.max_age = max_age
        self._lock = threading.Lock()
        self._hits = 0
        self._misses = 0

        # Ensure cache directory exists
        self.cache_dir.mkdir(parents=True, exist_ok=True)

    def _compute_cache_key(self, content: str, policies: list[str]) -> str:
        """Compute cache key from content and policies.

        The key is composed of:
        - First 16 chars of SHA-256 hash of content
        - First 16 chars of SHA-256 hash of sorted policy IDs

        Args:
            content: File content.
            policies: List of policy IDs to evaluate against.

        Returns:
            Cache key string in format "{content_hash}_{policies_hash}".
        """
        content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
        policies_str = ",".join(sorted(policies))
        policies_hash = hashlib.sha256(policies_str.encode()).hexdigest()[:16]
        return f"{content_hash}_{policies_hash}"

    def _get_cache_path(self, cache_key: str) -> Path:
        """Get the path for a cache entry.

        Uses first 2 characters of key as subdirectory for better
        filesystem performance with many cache files.

        Args:
            cache_key: The cache key.

        Returns:
            Path to the cache file.
        """
        subdir = cache_key[:2]
        return self.cache_dir / subdir / f"{cache_key}.json"

    def get(
        self,
        file_path: str,
        content: str,
        policies: list[str],
    ) -> CachedEvaluation | None:
        """Get cached evaluation result if available and valid.

        Returns None if:
        - No cache entry exists
        - Cache entry is expired (older than max_age)
        - Cache entry is corrupted

        Args:
            file_path: Path to the file being evaluated.
            content: Current content of the file.
            policies: List of policy IDs to evaluate against.

        Returns:
            CachedEvaluation if cache hit, None otherwise.
        """
        cache_key = self._compute_cache_key(content, policies)
        cache_path = self._get_cache_path(cache_key)

        with self._lock:
            if not cache_path.exists():
                self._misses += 1
                logger.debug(f"Cache miss (not found): {file_path}")
                return None

            try:
                data = json.loads(cache_path.read_text())
                cached_at = data.get("cached_at", 0)

                # Check if expired
                if time.time() - cached_at > self.max_age:
                    self._misses += 1
                    logger.debug(f"Cache miss (expired): {file_path}")
                    # Clean up expired entry
                    cache_path.unlink(missing_ok=True)
                    return None

                self._hits += 1
                logger.debug(f"Cache hit: {file_path}")

                return CachedEvaluation(
                    file_path=data["file_path"],
                    passed=data["passed"],
                    violations=data.get("violations", []),
                    cached_at=cached_at,
                    content_hash=data.get("content_hash", ""),
                    policies_hash=data.get("policies_hash", ""),
                )

            except (json.JSONDecodeError, KeyError) as e:
                self._misses += 1
                logger.warning(f"Cache corruption for {file_path}: {e}")
                # Clean up corrupted entry
                cache_path.unlink(missing_ok=True)
                return None

    def set(
        self,
        file_path: str,
        content: str,
        policies: list[str],
        passed: bool,
        violations: list[dict[str, Any]] | list,
    ) -> None:
        """Cache an evaluation result.

        Args:
            file_path: Path to the evaluated file.
            content: Content of the file at evaluation time.
            policies: List of policy IDs evaluated against.
            passed: Whether the evaluation passed.
            violations: List of violation dictionaries or Violation objects.
        """
        cache_key = self._compute_cache_key(content, policies)
        cache_path = self._get_cache_path(cache_key)

        # Convert violations to dicts if they're objects
        violations_data = []
        for v in violations:
            if hasattr(v, "to_dict"):
                violations_data.append(v.to_dict())
            elif isinstance(v, dict):
                violations_data.append(v)
            else:
                # Try to convert dataclass or similar
                violations_data.append(vars(v) if hasattr(v, "__dict__") else str(v))

        data = {
            "file_path": file_path,
            "passed": passed,
            "violations": violations_data,
            "cached_at": time.time(),
            "content_hash": hashlib.sha256(content.encode()).hexdigest()[:16],
            "policies_hash": hashlib.sha256(",".join(sorted(policies)).encode()).hexdigest()[:16],
        }

        with self._lock:
            cache_path.parent.mkdir(parents=True, exist_ok=True)
            cache_path.write_text(json.dumps(data, indent=2))
            logger.debug(f"Cached result for {file_path}")

    def invalidate(self, file_path: str | None = None) -> int:
        """Invalidate cache entries.

        Args:
            file_path: If provided, invalidates entries for this file only.
                If None, invalidates all cache entries.

        Returns:
            Number of cache entries invalidated.
        """
        invalidated = 0

        with self._lock:
            if file_path is None:
                # Invalidate all entries
                for cache_file in self.cache_dir.rglob("*.json"):
                    try:
                        cache_file.unlink()
                        invalidated += 1
                    except OSError:
                        pass
                logger.info(f"Invalidated all {invalidated} cache entries")
            else:
                # Invalidate entries for specific file
                # We need to scan all entries since key is content-based, not path-based
                for cache_file in self.cache_dir.rglob("*.json"):
                    try:
                        data = json.loads(cache_file.read_text())
                        if data.get("file_path") == file_path:
                            cache_file.unlink()
                            invalidated += 1
                    except (json.JSONDecodeError, OSError):
                        pass
                if invalidated:
                    logger.debug(f"Invalidated {invalidated} cache entries for {file_path}")

        return invalidated

    def prune_expired(self) -> int:
        """Remove expired cache entries.

        Returns:
            Number of entries pruned.
        """
        pruned = 0
        now = time.time()

        with self._lock:
            for cache_file in self.cache_dir.rglob("*.json"):
                try:
                    data = json.loads(cache_file.read_text())
                    if now - data.get("cached_at", 0) > self.max_age:
                        cache_file.unlink()
                        pruned += 1
                except (json.JSONDecodeError, OSError):
                    # Remove corrupted files too
                    try:
                        cache_file.unlink()
                        pruned += 1
                    except OSError:
                        pass

        if pruned:
            logger.info(f"Pruned {pruned} expired cache entries")
        return pruned

    def stats(self) -> dict[str, Any]:
        """Get cache statistics.

        Returns:
            Dictionary with cache statistics:
            - hits: Number of cache hits
            - misses: Number of cache misses
            - hit_ratio: Hit ratio (0.0 to 1.0)
            - size: Number of cache entries on disk
            - size_bytes: Total size of cache in bytes
        """
        total = self._hits + self._misses
        hit_ratio = self._hits / total if total > 0 else 0.0

        # Count entries and size
        entry_count = 0
        total_bytes = 0
        for cache_file in self.cache_dir.rglob("*.json"):
            entry_count += 1
            try:
                total_bytes += cache_file.stat().st_size
            except OSError:
                pass

        return {
            "hits": self._hits,
            "misses": self._misses,
            "hit_ratio": round(hit_ratio, 3),
            "size": entry_count,
            "size_bytes": total_bytes,
        }

    def clear(self) -> None:
        """Clear all cache entries and reset statistics."""
        with self._lock:
            self._hits = 0
            self._misses = 0
            for cache_file in self.cache_dir.rglob("*.json"):
                try:
                    cache_file.unlink()
                except OSError:
                    pass

            # Clean up empty subdirectories
            for subdir in self.cache_dir.iterdir():
                if subdir.is_dir():
                    try:
                        subdir.rmdir()  # Only removes if empty
                    except OSError:
                        pass

        logger.info("Cleared file cache")
