"""File-based cache for generated code."""

from __future__ import annotations

import json
import hashlib
from dataclasses import dataclass, asdict
from datetime import datetime
from pathlib import Path
from typing import Optional


@dataclass
class CacheEntry:
    """A single cached code entry."""

    class_name: str
    attr_name: str
    code: str
    code_type: str  # method, property, classmethod, staticmethod
    docstring: str
    dependencies: list[str]
    created_at: str
    checksum: str  # SHA256 of the code
    packages: list[str] = None  # PyPI packages

    def __post_init__(self):
        if self.packages is None:
            self.packages = []

    @classmethod
    def create(
        cls,
        class_name: str,
        attr_name: str,
        code: str,
        code_type: str = "method",
        docstring: str = "",
        dependencies: list[str] | None = None,
        packages: list[str] | None = None,
    ) -> CacheEntry:
        """Create a new cache entry with auto-generated metadata."""
        return cls(
            class_name=class_name,
            attr_name=attr_name,
            code=code,
            code_type=code_type,
            docstring=docstring,
            dependencies=dependencies or [],
            created_at=datetime.now().isoformat(),
            checksum=hashlib.sha256(code.encode()).hexdigest(),
            packages=packages or [],
        )


class ScryCache:
    """Manages the cache of generated code.

    Supports two modes:
    - Directory mode (legacy): Each class gets its own JSON file in a cache directory
    - Single-file mode: All classes in one JSON file

    Mode is auto-detected based on the path:
    - If path ends with .json -> single-file mode
    - If path is a directory (or no extension) -> directory mode
    """

    DEFAULT_CACHE_DIR = ".scry-run-cache"

    DEFAULT_CACHE_DIR = ".scry-run-cache"
    
    def __init__(self, cache_path: str | Path | None = None, read_only: bool = False):

        """Initialize cache with optional custom path.

        Args:
            cache_path: Path to cache directory or file.
                       If ends with .json, uses single-file mode.
                       Otherwise uses directory mode.
                       Defaults to .scry-run-cache/ directory.
            read_only: If True, prevents any modification to the cache.
        """
        self.read_only = read_only

        if cache_path is None:
            # Legacy mode: directory
            self.cache_path = Path(self.DEFAULT_CACHE_DIR)
            self._file_mode = False
        else:
            self.cache_path = Path(cache_path)
            # File mode if path ends with .json
            self._file_mode = self.cache_path.suffix == ".json"

        # For backwards compatibility, expose cache_dir for directory mode
        if not self._file_mode:
            self.cache_dir = self.cache_path
            self._ensure_cache_dir()

    def _ensure_cache_dir(self) -> None:
        """Create cache directory if it doesn't exist (directory mode only)."""
        if not self._file_mode:
            self.cache_path.mkdir(parents=True, exist_ok=True)

    # === Single-file mode methods ===

    def _load_all(self) -> dict[str, dict[str, dict]]:
        """Load entire cache from single JSON file.

        Returns:
            Nested dict: {class_name: {attr_name: entry_data}}
        """
        if not self.cache_path.exists():
            return {}

        with open(self.cache_path, "r", encoding="utf-8") as f:
            return json.load(f)

    def _save_all(self, data: dict[str, dict[str, dict]]) -> None:
        """Save entire cache to single JSON file.

        Args:
            data: Nested dict: {class_name: {attr_name: entry_data}}
        """
        # Ensure parent directory exists
        self.cache_path.parent.mkdir(parents=True, exist_ok=True)

        with open(self.cache_path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
    
    # === Directory mode methods ===

    def _get_class_cache_path(self, class_name: str) -> Path:
        """Get the cache file path for a specific class (directory mode only)."""
        return self.cache_path / f"{class_name}.json"

    def _load_class_cache(self, class_name: str) -> dict[str, CacheEntry]:
        """Load all cached entries for a class.

        Works in both modes:
        - Directory mode: loads from {class_name}.json
        - Single-file mode: loads class section from cache.json
        """
        if self._file_mode:
            all_data = self._load_all()
            class_data = all_data.get(class_name, {})
            return {
                attr_name: CacheEntry(**entry_data)
                for attr_name, entry_data in class_data.items()
            }

        # Directory mode
        cache_path = self._get_class_cache_path(class_name)

        if not cache_path.exists():
            return {}

        with open(cache_path, "r", encoding="utf-8") as f:
            data = json.load(f)

        return {
            attr_name: CacheEntry(**entry_data)
            for attr_name, entry_data in data.items()
        }

    def _save_class_cache(self, class_name: str, entries: dict[str, CacheEntry]) -> None:
        """Save all cached entries for a class.

        Works in both modes:
        - Directory mode: saves to {class_name}.json
        - Single-file mode: saves class section to cache.json
        """
        data = {
            attr_name: asdict(entry)
            for attr_name, entry in entries.items()
        }

        if self._file_mode:
            all_data = self._load_all()
            all_data[class_name] = data
            self._save_all(all_data)
            return

        # Directory mode
        cache_path = self._get_class_cache_path(class_name)

        with open(cache_path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
    
    def get(self, class_name: str, attr_name: str) -> Optional[CacheEntry]:
        """Get a cached code entry.
        
        Args:
            class_name: Name of the class
            attr_name: Name of the attribute
            
        Returns:
            CacheEntry if found, None otherwise
        """
        entries = self._load_class_cache(class_name)
        return entries.get(attr_name)
    
    def set(
        self,
        class_name: str,
        attr_name: str,
        code: str,
        code_type: str = "method",
        docstring: str = "",
        dependencies: list[str] | None = None,
        packages: list[str] | None = None,
    ) -> CacheEntry:
        """Cache a code entry.

        Args:
            class_name: Name of the class
            attr_name: Name of the attribute
            code: The generated code
            code_type: Type of code (method, property, etc.)
            docstring: Description of the code
            dependencies: List of import dependencies
            packages: List of PyPI packages required

        """
        if self.read_only:
             # In read-only mode, we pretend we set it but don't persist
             # This allows runtime usage of generated code without saving
            return CacheEntry.create(
                class_name=class_name,
                attr_name=attr_name,
                code=code,
                code_type=code_type,
                docstring=docstring,
                dependencies=dependencies,
                packages=packages,
            )

        entries = self._load_class_cache(class_name)


        entry = CacheEntry.create(
            class_name=class_name,
            attr_name=attr_name,
            code=code,
            code_type=code_type,
            docstring=docstring,
            dependencies=dependencies,
            packages=packages,
        )
        
        entries[attr_name] = entry
        self._save_class_cache(class_name, entries)
        
        return entry
    
    def delete(self, class_name: str, attr_name: str) -> bool:
        """Delete a cached entry.

        Args:
            class_name: Name of the class
            attr_name: Name of the attribute

        Returns:
            True if entry was deleted, False if it didn't exist or read-only
        """
        if self.read_only:
            return False

        entries = self._load_class_cache(class_name)


        if attr_name not in entries:
            return False

        del entries[attr_name]

        if self._file_mode:
            # Single-file mode: update the entire file
            all_data = self._load_all()
            if entries:
                all_data[class_name] = {
                    attr_name: asdict(entry)
                    for attr_name, entry in entries.items()
                }
            else:
                # Remove empty class from file
                all_data.pop(class_name, None)
            self._save_all(all_data)
        else:
            # Directory mode
            if entries:
                self._save_class_cache(class_name, entries)
            else:
                # Remove empty cache file
                cache_path = self._get_class_cache_path(class_name)
                cache_path.unlink(missing_ok=True)

        return True
    
    def prune(
        self,
        class_name: Optional[str] = None,
        attr_name: Optional[str] = None,
    ) -> int:
        """Prune cache entries.

        Args:
            class_name: If provided, only prune entries for this class
            attr_name: If provided along with class_name, only prune this specific entry

        Returns:
            Number of entries pruned (0 if read-only)
        """
        if self.read_only:
            return 0

        pruned = 0


        if class_name and attr_name:
            # Prune specific entry
            if self.delete(class_name, attr_name):
                pruned = 1
        elif class_name:
            # Prune all entries for a class
            entries = self._load_class_cache(class_name)
            pruned = len(entries)

            if self._file_mode:
                # Single-file mode: remove class from file
                all_data = self._load_all()
                all_data.pop(class_name, None)
                self._save_all(all_data)
            else:
                # Directory mode: remove class file
                cache_path = self._get_class_cache_path(class_name)
                cache_path.unlink(missing_ok=True)
        else:
            # Prune entire cache
            if self._file_mode:
                # Single-file mode: count entries and clear file
                all_data = self._load_all()
                for class_data in all_data.values():
                    pruned += len(class_data)
                self.cache_path.unlink(missing_ok=True)
            else:
                # Directory mode: remove all class files
                for cache_file in self.cache_path.glob("*.json"):
                    with open(cache_file, "r", encoding="utf-8") as f:
                        data = json.load(f)
                    pruned += len(data)
                    cache_file.unlink()

        return pruned
    
    def list_entries(self) -> list[CacheEntry]:
        """List all cached entries.

        Returns:
            List of all CacheEntry objects in the cache
        """
        entries = []

        if self._file_mode:
            # Single-file mode: load all classes from one file
            all_data = self._load_all()
            for class_name, class_data in all_data.items():
                for attr_name, entry_data in class_data.items():
                    entries.append(CacheEntry(**entry_data))
        else:
            # Directory mode: load from each class file
            for cache_file in self.cache_path.glob("*.json"):
                class_name = cache_file.stem
                class_entries = self._load_class_cache(class_name)
                entries.extend(class_entries.values())

        return entries
    
    def export(self) -> dict[str, dict[str, dict]]:
        """Export entire cache as a dictionary.

        Returns:
            Nested dict: {class_name: {attr_name: entry_data}}
        """
        if self._file_mode:
            # Single-file mode: just load and return the entire file
            return self._load_all()

        # Directory mode: aggregate from all class files
        result = {}

        for cache_file in self.cache_path.glob("*.json"):
            class_name = cache_file.stem
            with open(cache_file, "r", encoding="utf-8") as f:
                result[class_name] = json.load(f)

        return result
    
    def export_to_file(self, output_path: str | Path) -> None:
        """Export cache to a Python file with all generated code.
        
        Args:
            output_path: Path to write the Python file
        """
        output_path = Path(output_path)
        entries = self.list_entries()
        
        # Collect all dependencies
        all_deps = set()
        for entry in entries:
            all_deps.update(entry.dependencies)
        
        # Group entries by class
        by_class: dict[str, list[CacheEntry]] = {}
        for entry in entries:
            by_class.setdefault(entry.class_name, []).append(entry)
        
        lines = [
            '"""Generated code exported from scry-run cache."""',
            "",
        ]
        
        # Add imports
        if all_deps:
            for dep in sorted(all_deps):
                lines.append(dep)
            lines.append("")
        
        # Add code for each class
        for class_name, class_entries in sorted(by_class.items()):
            lines.append(f"# === {class_name} ===")
            lines.append("")
            
            for entry in sorted(class_entries, key=lambda e: e.attr_name):
                if entry.docstring:
                    lines.append(f"# {entry.docstring}")
                lines.append(f"# Type: {entry.code_type}")
                lines.append(entry.code)
                lines.append("")
        
        with open(output_path, "w", encoding="utf-8") as f:
            f.write("\n".join(lines))
