"""Libcache module for models."""

from __future__ import annotations

import json
import re
import shutil
import tarfile
import time
import zipfile
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from typing import Any, Final

from pytola.core.logger import get_logger

logger = get_logger(__name__)

DEFAULT_CACHE_DIR = Path.home() / ".pytola" / ".cache" / "python-libs"
MAX_DEPTH: Final[int] = 50  # Maximum recursion depth to prevent infinite loops


@dataclass
class CacheMetadata:
    """Metadata for cached package."""

    name: str
    version: str | None
    path: str
    timestamp: float


DEV_TOOLS = frozenset(
    {
        "sphinx",
        "sphinx_rtd_theme",
        "watchdog",
        "pytest",
        "coverage",
        "black",
        "mypy",
        "flake8",
        "pylint",
        "isort",
        "pre-commit",
        "tox",
        "nose",
        "unittest",
        "mock",
    }
)
DEV_PATTERNS = frozenset({"dev", "test", "docs", "lint", "example"})
TYPING_PATTERNS = frozenset({"stubs", "typing", "types"})


def normalize_package_name(name: str) -> str:
    """Normalize package name to lowercase with underscores.

    Args:
        name: Package name to normalize

    Returns
    -------
        Normalized package name
    """
    return name.lower().replace("-", "_")


def should_skip_dependency(req_name: str, has_extras: bool = False) -> bool:
    """Check if a dependency should be skipped based on common patterns.

    Args:
        req_name: Package name
        has_extras: Whether the requirement has extras

    Returns
    -------
        True if should skip, False otherwise
    """
    # Skip extras
    if has_extras:
        return True

    req_lower = req_name.lower()
    normalized_req = req_lower.replace("-", "_")

    # Skip dev/test/docs/lint/example patterns
    if any(keyword in req_lower for keyword in DEV_PATTERNS):
        return True

    # Skip typing/stubs dependencies
    if any(keyword in req_lower for keyword in TYPING_PATTERNS):
        return True

    # Skip common dev tools
    return normalized_req in DEV_TOOLS


@dataclass(frozen=False)
class LibraryCache:
    """Manage local cache for Python packages."""

    cache_dir: Path = field(default_factory=lambda: DEFAULT_CACHE_DIR)
    _dependencies_cache: dict[Path, set[str]] = field(default_factory=dict)

    def __post_init__(self) -> None:
        """Initialize the cache directory if it doesn't exist."""
        self.cache_dir.mkdir(parents=True, exist_ok=True)

    @cached_property
    def metadata_file(self) -> Path:
        """Path to the metadata file for the cache directory.

        Returns
        -------
            Path to the metadata file
        """
        return self.cache_dir / "metadata.json"

    @cached_property
    def wheel_files(self) -> list[Path]:
        """List of wheel files in the cache directory.

        Returns
        -------
            List of wheel files
        """
        return list(self.cache_dir.glob("*.whl"))

    @cached_property
    def sdist_files(self) -> list[Path]:
        """List of source distribution files in the cache directory.

        Returns
        -------
            List of source distribution files
        """
        return list(self.cache_dir.glob("*.tar.gz")) + list(
            self.cache_dir.glob("*.zip"),
        )

    def collect_dependencies_from_list(self, dependency_list: list[str]) -> set[str]:
        """Recursively collect all dependencies from package files (wheel or sdist).

        Args:
            dependency_list: List of root package names to start from

        Returns
        -------
            Set of all required package names (normalized)
        """
        all_packages: set[str] = set()
        visited: set[str] = set()
        visit_stack: dict[str, int] = {}  # Track visit depth for cycle detection

        def visit(pkg_name: str, level: int = 0) -> None:
            """Visit a package and collect its dependencies."""
            # Normalize package name for consistency
            normalized_pkg_name = pkg_name.lower().replace("-", "_")

            # Check for cycles
            if normalized_pkg_name in visit_stack:
                logger.warning(
                    f"Potential circular dependency detected: {normalized_pkg_name} (current depth: {level}, "
                    f"previous depth: {visit_stack[normalized_pkg_name]})",
                )
                return

            # Check depth limit
            if level > MAX_DEPTH:
                logger.warning(
                    f"Maximum dependency depth ({MAX_DEPTH}) reached for {normalized_pkg_name}, stopping recursion",
                )
                return

            # Skip if already visited
            if normalized_pkg_name in visited:
                return

            # Mark as visited and track depth
            visited.add(normalized_pkg_name)
            visit_stack[normalized_pkg_name] = level
            all_packages.add(normalized_pkg_name)

            # Process dependencies if package exists in map
            package_path = self.package_map.get(normalized_pkg_name)
            if package_path:
                deps = self._extract_dependencies_from_wheel(package_path)
                logger.debug(f"{'  ' * level}{normalized_pkg_name} -> {deps}")
                for dep in deps:
                    visit(dep, level + 1)

            # Remove from stack when done
            visit_stack.pop(normalized_pkg_name, None)

        for pkg_name in dependency_list:
            visit(pkg_name)

        logger.info(f"Collected {len(all_packages)} packages: {all_packages}")
        return all_packages

    @property
    def package_map(self) -> dict[str, Path]:
        """Create a mapping of package names to their file paths with improved efficiency."""
        packages: dict[str, Path] = {}

        # Process wheel files first (they take precedence)
        for wheel_file in self.wheel_files:
            pkg_name = self._extract_package_name_from_wheel(wheel_file)
            if pkg_name:
                normalized_pkg_name = normalize_package_name(pkg_name)
                packages[normalized_pkg_name] = wheel_file

        # Add sdist files only if the package isn't already in the map
        for sdist_file in self.sdist_files:
            pkg_name = self._extract_package_name_from_sdist(sdist_file)
            if pkg_name:
                normalized_pkg_name = normalize_package_name(pkg_name)
                if normalized_pkg_name not in packages:
                    packages[normalized_pkg_name] = sdist_file

        return packages

    @cached_property
    def cache_size(self) -> int:
        """Calculate total size of cache in bytes."""
        if not self.cache_dir.exists():
            return 0

        # Use generator expression for memory efficiency
        return sum(file_path.stat().st_size for file_path in self.cache_dir.rglob("*") if file_path.is_file())

    @cached_property
    def package_count(self) -> int:
        """Get the count of packages in cache."""
        return len(self.package_map)

    @cached_property
    def cache_stats(self) -> dict[str, int]:
        """Get detailed cache statistics."""
        return {
            "total_packages": self.package_count,
            "wheel_count": len(self.wheel_files),
            "sdist_count": len(self.sdist_files),
            "cache_size_bytes": self.cache_size,
        }

    def get_package_path(
        self,
        package_name: str,
        version: str | None = None,
    ) -> Path | None:
        """Get cached package path if available.

        Args:
            package_name: Name of the package
            version: Version (optional)

        Returns
        -------
            Path to cached package or None
        """
        normalized_name = normalize_package_name(package_name)

        # First try filesystem lookup for wheel files (works even if metadata is missing)
        for whl_file in self.cache_dir.glob("*.whl"):
            parsed_name = self._extract_package_name_from_wheel(whl_file)
            if parsed_name == normalized_name:
                logger.debug(f"Cache hit (filesystem wheel): {package_name}")
                return whl_file

        # Try filesystem lookup for sdist files (.tar.gz, .zip)
        for sdist_file in self.cache_dir.glob("*.tar.gz"):
            parsed_name = self._extract_package_name_from_sdist(sdist_file)
            if parsed_name == normalized_name:
                logger.debug(f"Cache hit (filesystem sdist): {package_name}")
                return sdist_file
        for sdist_file in self.cache_dir.glob("*.zip"):
            parsed_name = self._extract_package_name_from_sdist(sdist_file)
            if parsed_name == normalized_name:
                logger.debug(f"Cache hit (filesystem sdist): {package_name}")
                return sdist_file

        # Fallback to metadata lookup
        metadata = self._load_metadata()
        for info in metadata.values():
            if info["name"] == normalized_name and (version is None or info.get("version") == version):
                path = self.cache_dir / info["path"]
                if path.exists():
                    logger.debug(f"Cache hit (metadata): {package_name}")
                    return path

        logger.debug(f"Cache miss: {package_name}")
        return None

    @staticmethod
    def _extract_package_name_from_wheel(wheel_file: Path) -> str | None:
        """Extract package name from wheel file.

        Args:
            wheel_file: Path to wheel file

        Returns
        -------
            Package name or None
        """
        try:
            filename = wheel_file.stem  # Remove .whl extension
            parts = filename.split("-")
            if parts:
                return normalize_package_name(parts[0])
        except Exception:
            pass
        return None

    @staticmethod
    def _extract_package_name_from_sdist(sdist_file: Path) -> str | None:
        """Extract package name from source distribution file (.tar.gz or .zip).

        Args:
            sdist_file: Path to sdist file

        Returns
        -------
            Package name or None
        """
        try:
            # Handle .tar.gz files (e.g., package_name-1.0.0.tar.gz)
            if sdist_file.suffixes and ".tar" in sdist_file.suffixes and ".gz" in sdist_file.suffixes:
                # Remove both .tar.gz extensions by removing the last 7 characters (.tar.gz)
                stem_without_ext = sdist_file.stem  # This removes .gz, leaving package-1.0.0.tar
                # Now remove the remaining .tar
                if stem_without_ext.endswith(".tar"):
                    stem_without_ext = stem_without_ext[:-4]  # Remove .tar
                parts = stem_without_ext.rsplit(
                    "-",
                    1,
                )  # Split from right: ["package_name", "1.0.0"]
                if len(parts) >= 1 and parts[0]:
                    return normalize_package_name(parts[0])
            # Handle .zip files
            elif sdist_file.suffix == ".zip":
                filename = sdist_file.stem  # Remove .zip extension
                parts = filename.rsplit("-", 1)
                if len(parts) >= 1 and parts[0]:
                    return normalize_package_name(parts[0])
        except Exception as e:
            logger.debug(f"Failed to extract package name from {sdist_file}: {e}")
        return None

    def _extract_dependencies_from_wheel(self, wheel_file: Path) -> set[str]:
        """Extract dependencies from wheel METADATA file with caching.

        Args:
            wheel_file: Path to wheel or sdist file

        Returns
        -------
            Set of package names (normalized)
        """
        # Check cache first
        if wheel_file in self._dependencies_cache:
            return self._dependencies_cache[wheel_file]

        # Check if it's an sdist file (.tar.gz or .zip)
        if wheel_file.suffix in {".gz", ".zip"}:
            dependencies = self._extract_dependencies_from_sdist(wheel_file)
            self._dependencies_cache[wheel_file] = dependencies
            return dependencies

        # Early return if wheel file doesn't exist
        if not wheel_file.exists():
            logger.warning(f"Wheel file does not exist: {wheel_file}")
            self._dependencies_cache[wheel_file] = set()
            return set()

        try:
            with zipfile.ZipFile(wheel_file, "r") as zf:
                # Find metadata file
                metadata_files = [name for name in zf.namelist() if name.endswith("METADATA")]

                if not metadata_files:
                    dependencies = set()
                else:
                    metadata_content = zf.read(metadata_files[0]).decode(
                        "utf-8",
                        errors="ignore",
                    )
                    dependencies = self._parse_metadata_content(metadata_content)

            # Cache the result
            self._dependencies_cache[wheel_file] = dependencies
            return dependencies
        except Exception as e:
            logger.warning(
                f"Failed to extract dependencies from {wheel_file.name}: {e}",
            )
            return set()

    def _extract_dependencies_from_sdist(self, sdist_file: Path) -> set[str]:
        """Extract dependencies from source distribution file with caching.

        Args:
            sdist_file: Path to sdist file (.tar.gz or .zip)

        Returns
        -------
            Set of package names (normalized)
        """
        dependencies: set[str] = set()

        try:
            # Handle .tar.gz files
            if sdist_file.suffix == ".gz":
                with tarfile.open(sdist_file, "r:gz") as tf:
                    for member in tf.getmembers():
                        # Look for PKG-INFO or METADATA file in the root of the package
                        if member.name.endswith(("PKG-INFO", "METADATA")):
                            # Only use PKG-INFO/METADATA files in the root directory
                            # Count the number of slashes in the path
                            path_parts = member.name.split("/")
                            if len(path_parts) == 2 or (
                                len(path_parts) == 3 and path_parts[2] in {"PKG-INFO", "METADATA"}
                            ):
                                content = tf.extractfile(member)
                                if content:
                                    metadata_content = content.read().decode(
                                        "utf-8",
                                        errors="ignore",
                                    )
                                    dependencies = self._parse_metadata_content(
                                        metadata_content,
                                    )
                                    logger.debug(
                                        f"Extracted dependencies from {member.name} in {sdist_file.name}",
                                    )
                                    break
            # Handle .zip files
            elif sdist_file.suffix == ".zip":
                with zipfile.ZipFile(sdist_file, "r") as zf:
                    for name in zf.namelist():
                        # Look for PKG-INFO or METADATA file in the root of the package
                        if name.endswith(("PKG-INFO", "METADATA")):
                            path_parts = name.split("/")
                            if len(path_parts) == 2 or (
                                len(path_parts) == 3 and path_parts[2] in {"PKG-INFO", "METADATA"}
                            ):
                                metadata_content = zf.read(name).decode(
                                    "utf-8",
                                    errors="ignore",
                                )
                                dependencies = self._parse_metadata_content(
                                    metadata_content,
                                )
                                logger.debug(
                                    f"Extracted dependencies from {name} in {sdist_file.name}",
                                )
                                break
        except Exception as e:
            logger.warning(
                f"Failed to extract dependencies from sdist {sdist_file.name}: {e}",
            )

        return dependencies

    @staticmethod
    def _parse_metadata_content(metadata_content: str) -> set[str]:
        """Parse metadata content (PKG-INFO or METADATA) to extract dependencies.

        Args:
            metadata_content: Content of PKG-INFO or METADATA file

        Returns
        -------
            Set of package names (normalized)
        """
        dependencies: set[str] = set()
        try:
            for line in metadata_content.splitlines():
                # Look for Requires-Dist or Requires field
                if line.startswith(("Requires-Dist:", "Requires:")):
                    if line.startswith("Requires:"):
                        # Requires field contains comma-separated list
                        dep_str = line.split(":", 1)[1].strip()
                        for req_str in re.split(r",\s*", dep_str):
                            req_str = req_str.strip()
                            if req_str:
                                dependencies.update(
                                    LibraryCache._parse_single_requirement(req_str),
                                )
                    else:
                        # Requires-Dist field
                        dep_str = line.split(":", 1)[1].strip()
                        dependencies.update(
                            LibraryCache._parse_single_requirement(dep_str),
                        )
        except Exception as e:
            logger.debug(f"Failed to parse metadata content: {e}")

        return dependencies

    @staticmethod
    def _parse_single_requirement(req_str: str) -> set[str]:
        """Parse a single requirement string and extract package name.

        Args:
            req_str: Requirement string (e.g., "numpy>=1.20.0", "package[extra]>=1.0")

        Returns
        -------
            Set containing the normalized package name, or empty set if should skip
        """
        try:
            # Skip extras dependencies
            if re.search(
                r'extra\s*==\s*["\']?([^"\';\s]+)["\']?',
                req_str,
                re.IGNORECASE,
            ):
                logger.debug(f"Skipping extra dependency: {req_str}")
                return set()

            from packaging.requirements import Requirement

            req = Requirement(req_str)
            if not should_skip_dependency(req.name, bool(req.extras)):
                dep_name = normalize_package_name(req.name)
                logger.debug(f"Found core dependency: {dep_name}")
                return {dep_name}
        except Exception:
            pass

        return set()

    def add_package(
        self,
        package_name: str,
        package_path: Path,
        version: str | None = None,
    ) -> None:
        """Add package to cache.

        Args:
            package_name: Name of the package
            package_path: Path to package files
            version: Package version
        """
        # Normalize package name to ensure consistency
        normalized_name = normalize_package_name(package_name)

        # Copy package files to cache (flat structure for wheels, nested for dirs)
        if package_path.is_dir():
            dest_dir = self.cache_dir / normalized_name
            if dest_dir.exists():
                shutil.rmtree(dest_dir)
            shutil.copytree(package_path, dest_dir)
            relative_path = normalized_name
        else:
            dest_file = self.cache_dir / package_path.name
            shutil.copy2(package_path, dest_file)
            relative_path = package_path.name

        # Update metadata using CacheMetadata dataclass
        metadata = self._load_metadata()
        metadata[str(package_path)] = CacheMetadata(
            name=normalized_name,
            version=version,
            path=relative_path,
            timestamp=time.time(),
        ).__dict__
        self._save_metadata(metadata)

        logger.info(f"Cached package: {normalized_name}")

    def _load_metadata(self) -> dict[str, Any]:
        """Load cache metadata.

        Returns
        -------
            Metadata dictionary
        """
        if self.metadata_file.exists():
            try:
                with Path(self.metadata_file).open(encoding="utf-8") as f:
                    return json.load(f)
            except Exception as e:
                logger.warning(f"Failed to load cache metadata: {e}")

        return {}

    def _save_metadata(self, metadata: dict[str, Any]) -> None:
        """Save cache metadata.

        Args:
            metadata: Metadata dictionary
        """
        with Path(self.metadata_file).open("w", encoding="utf-8") as f:
            json.dump(metadata, f, indent=2)

    @staticmethod
    def _should_skip_dist_info(file_path: Path) -> bool:
        """Check if the file path should be skipped because it's a dist-info directory."""
        if file_path.name.endswith(".dist-info"):
            return True
        # Check if any parent directory ends with .dist-info
        return any(part.endswith(".dist-info") for part in file_path.parts)

    def clear_cache(self) -> None:
        """Clear all cached packages."""
        if self.cache_dir.exists():
            shutil.rmtree(self.cache_dir)
            self.cache_dir.mkdir(parents=True, exist_ok=True)
        self._dependencies_cache.clear()  # Clear in-memory dependencies cache
        logger.info("Cache cleared")
