"""Libpacker module for components."""

from __future__ import annotations

import json
import re
import shutil
import subprocess
import tempfile
import time
import zipfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from re import Pattern
from typing import ClassVar, Final

from pytola.core.logger import get_logger

from ..models.dependency import Dependency
from ..models.libcache import DEFAULT_CACHE_DIR, LibraryCache
from ..models.libdownloader import LibraryDownloader
from ..models.project import Project
from ..models.solution import Solution

logger = get_logger(__name__)

DEFAULT_MAX_WORKERS: Final[int] = 4
DEFAULT_MIRROR: Final[str] = "aliyun"
DEFAULT_OPTIMIZE: Final[bool] = True
CONFIG_FILE = Path.home() / ".pytola" / "pylibpack.json"


@dataclass
class PyLibPackerConfig:
    """Configuration for PyLibPack with persistent settings."""

    cache_dir: Path | None = None
    mirror: str = DEFAULT_MIRROR
    optimize: bool = DEFAULT_OPTIMIZE
    max_workers: int = DEFAULT_MAX_WORKERS

    def __init__(
        self,
        cache_dir: Path | None = None,
        mirror: str = DEFAULT_MIRROR,
        optimize: bool = DEFAULT_OPTIMIZE,
        max_workers: int = DEFAULT_MAX_WORKERS,
    ) -> None:
        # Track which parameters were explicitly provided
        self._explicitly_set = {}

        if cache_dir is not None:
            self._explicitly_set["cache_dir"] = True
        if mirror != DEFAULT_MIRROR:
            self._explicitly_set["mirror"] = True
        if optimize != DEFAULT_OPTIMIZE:
            self._explicitly_set["optimize"] = True
        if max_workers != DEFAULT_MAX_WORKERS:
            self._explicitly_set["max_workers"] = True

        # Set the values
        self.cache_dir = cache_dir
        self.mirror = mirror
        self.optimize = optimize
        self.max_workers = max_workers

        # Apply defaults for unset values
        if self.cache_dir is None:
            self.cache_dir = DEFAULT_CACHE_DIR

        self.cache_dir.mkdir(parents=True, exist_ok=True)

        # Load configuration from file if it exists
        if CONFIG_FILE.exists():
            try:
                config_data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
                # Update configuration items, but only for those not explicitly set
                for key, value in config_data.items():
                    if (
                        hasattr(self, key)
                        and isinstance(value, type(getattr(self, key)))
                        and key not in self._explicitly_set
                    ):
                        setattr(self, key, value)
            except (json.JSONDecodeError, TypeError, AttributeError) as e:
                logger.warning(f"Could not load config from {CONFIG_FILE}: {e}")

    def save(self) -> None:
        """Save current configuration to file."""
        CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
        config_dict = {
            "cache_dir": str(self.cache_dir),
            "mirror": self.mirror,
            "optimize": self.optimize,
            "max_workers": self.max_workers,
        }
        CONFIG_FILE.write_text(json.dumps(config_dict, indent=4), encoding="utf-8")


@dataclass
class PackResult:
    """Result of packing project dependencies."""

    success: bool
    project: str
    total: int
    successful: int
    failed: int
    packages_dir: str
    extracted_packages: list[str] = field(default_factory=list)


@dataclass
class OptimizationRule:
    """Defines an optimization rule for a specific library.

    Attributes
    ----------
        library_name: The name of the library to apply the rule to.
        exclude_patterns: A list of patterns to exclude from the library.
        include_patterns: A list of patterns to include in the library.

    """

    library_name: str = field(default_factory=str)
    exclude_patterns: list[str] = field(default_factory=list)
    include_patterns: list[str] = field(default_factory=list)

    def __post_init__(self) -> None:
        """Compile regex patterns after initialization."""
        self.exclude_compiled: list[Pattern] = [re.compile(p) for p in self.exclude_patterns]
        self.include_compiled: list[Pattern] = [re.compile(p) for p in self.include_patterns]


@dataclass(frozen=False)
class SelectiveExtractionStrategy:
    """Optimization strategy that applies inclusion/exclusion rules to specific libraries.

    This strategy works as follows:
    1. First, apply universal exclusion rules (doc, test, example, demo, etc.)
    2. Then, apply library-specific exclusion rules
    3. Finally, apply inclusion rules (only files matching include patterns are kept)
    """

    # Universal exclusion patterns - applied to all libraries
    UNIVERSAL_EXCLUDE_PATTERNS: ClassVar[frozenset[str]] = frozenset(
        {
            "doc",
            "docs",
            "test",
            "tests",
            "example",
            "examples",
            "demo",
            "demos",
            "sample",
            "samples",
            "benchmark",
            "benchmarks",
            "tutorial",
            "tutorials",
            "notebook",
            "notebooks",
            "license",
            "licenses",
        }
    )

    def __init__(
        self,
        rules: list[OptimizationRule] | None = None,
        apply_universal_rules: bool = True,
    ) -> None:
        """Initialize the strategy with optimization rules.

        Args:
            rules: List of optimization rules to apply
            apply_universal_rules: Whether to apply universal exclusion rules (default: True)
        """
        self.rules: dict[str, OptimizationRule] = {}
        self.apply_universal_rules = apply_universal_rules

        if rules:
            for rule in rules:
                self.rules[rule.library_name.lower()] = rule

        # Default rules for common libraries
        if not rules:
            self._setup_default_rules()

        # Compile universal exclusion patterns for faster matching
        self._universal_exclude_compiled = [
            re.compile(f"(^|/)({pattern})(/|$)", re.IGNORECASE) for pattern in self.UNIVERSAL_EXCLUDE_PATTERNS
        ]

    def _setup_default_rules(self) -> None:
        """Set up default optimization rules for common libraries.

        This method loads JSON rule files from the rules directory and
        creates OptimizationRule objects for common libraries.
        """
        # Get the rules directory
        # Navigate up from components/ to reach the main pypack directory
        rules_dir = Path(__file__).parent.parent / "assets" / "lib_rules"

        if not rules_dir.exists() or not rules_dir.is_dir():
            logger.warning(f"Rules directory not found: {rules_dir}")
            return

        # Load all JSON rule files
        for rule_file in rules_dir.glob("*.json"):
            try:
                with Path(rule_file).open(encoding="utf-8") as f:
                    rule_data = json.load(f)

                # Convert JSON data to OptimizationRule
                rule = OptimizationRule(
                    library_name=rule_data["library_name"],
                    exclude_patterns=rule_data["exclude_patterns"],
                    include_patterns=rule_data["include_patterns"],
                )

                self.rules[rule.library_name.lower()] = rule
                logger.debug(
                    f"Loaded optimization rule for {rule.library_name} from {rule_file.name}",
                )

            except Exception as e:
                logger.warning(f"Failed to load rule from {rule_file.name}: {e}")

    def _matches_universal_exclude_pattern(self, relative_path: str) -> bool:
        """Check if file path matches any universal exclusion pattern.

        Args:
            relative_path: Relative path to the file

        Returns
        -------
            True if path should be excluded, False otherwise
        """
        return any(pattern.search(relative_path) for pattern in self._universal_exclude_compiled)

    def should_extract_file(self, library_name: str, file_path: Path) -> bool:
        """Determine if a file should be extracted based on library-specific rules.

        Args:
            library_name: Name of the library
            file_path: Path to the file to check

        Returns
        -------
            True if the file should be extracted, False otherwise
        """
        lib_name_lower = library_name.lower()
        relative_path = file_path.as_posix().lower()

        # First, apply universal exclusion rules (applied to all libraries)
        if self.apply_universal_rules and self._matches_universal_exclude_pattern(
            relative_path,
        ):
            logger.debug(
                f"Excluding {file_path} from {library_name} (matches universal exclusion pattern)",
            )
            return False

        # If no specific rule exists for this library, extract everything
        if lib_name_lower not in self.rules:
            logger.debug(f"No specific rules for {library_name}, including {file_path}")
            return True

        rule = self.rules[lib_name_lower]

        logger.debug(
            f"Checking {file_path} for {library_name} with {len(rule.exclude_compiled)} "
            f"exclude and {len(rule.include_compiled)} include patterns",
        )

        for exclude_pattern in rule.exclude_compiled:
            if exclude_pattern.search(relative_path):
                logger.debug(
                    f"Excluding {file_path} from {library_name} (matches exclude pattern: {exclude_pattern.pattern})",
                )
                return False

        # If inclusion patterns are defined, only include files that match at least one
        if rule.include_compiled:
            for include_pattern in rule.include_compiled:
                if include_pattern.search(relative_path):
                    logger.debug(
                        f"Including {file_path} from {library_name} "
                        f"(matches include pattern: {include_pattern.pattern})",
                    )
                    return True
            # If we have inclusion rules but the file doesn't match any, exclude it
            logger.debug(
                f"Excluding {file_path} from {library_name} (doesn't match any include patterns)",
            )
            return False

        # If no inclusion rules are defined, include the file (after exclusion check)
        logger.debug(
            f"Including {file_path} from {library_name} (passed exclusion filters)",
        )
        return True

    def get_library_names_with_rules(self) -> set[str]:
        """Get the names of libraries that have optimization rules defined.

        Returns
        -------
            Set of library names with optimization rules
        """
        return set(self.rules.keys())


@dataclass(frozen=True)
class PyLibPacker:
    """Main library packer class."""

    working_dir: Path
    config: PyLibPackerConfig

    @cached_property
    def cache(self) -> LibraryCache:
        """Library cache instance.

        Returns
        -------
            LibraryCache instance
        """
        return LibraryCache(cache_dir=self.config.cache_dir or DEFAULT_CACHE_DIR)

    @cached_property
    def downloader(self) -> LibraryDownloader:
        """Library downloader instance.

        Returns
        -------
            LibraryDownloader instance
        """
        return LibraryDownloader(
            parent=self,
            cache=self.cache,
            _mirror=self.config.mirror,
        )

    @cached_property
    def optimization_strategy(self) -> SelectiveExtractionStrategy | None:
        """Selective extraction strategy instance.

        Returns
        -------
            SelectiveExtractionStrategy instance or None if optimization is disabled
        """
        return SelectiveExtractionStrategy() if self.config.optimize else None

    @cached_property
    def solution(self) -> Solution:
        """Solution instance representing the project solution."""
        return Solution.from_directory(root_dir=self.working_dir)

    @cached_property
    def projects(self) -> dict[str, Project]:
        """Get the projects in the solution.

        Returns
        -------
            dict[str, Project]: Dictionary of projects
        """
        # Return projects as a dictionary mapping project names to Project objects
        # This follows the Solution API correctly
        return {project.name: project for project in self.solution.projects.values()}

    @cached_property
    def project_count(self) -> int:
        """Get the count of projects to avoid repeated computation."""
        return len(self.projects)

    @cached_property
    def working_dir_size(self) -> int:
        """Calculate total size of the working directory in bytes."""
        if not self.working_dir.exists():
            return 0

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

    def pack_project(self, project: Project) -> PackResult:
        """Pack dependencies for a single project.

        Args:
            project: Project information
            output_dir: Output directory
            max_workers: Maximum concurrent downloads

        Returns
        -------
            PackResult containing packing statistics

        Raises
        ------
            ValueError: If project has invalid configuration
            RuntimeError: If packing fails due to system issues
        """
        logger.info(f"{120 * '='}")
        logger.info(f"Packing dependencies for project: `{project.name}`")

        download_result = self.downloader.download_packages(project)

        # Recursively collect all dependencies (pass cache instance for dependency extraction)
        all_packages = self.cache.collect_dependencies_from_list(
            list(download_result.results),
        )

        # Extract all required packages (keep order of dependency resolution)
        extracted_packages = []
        for pkg_name in all_packages:
            logger.info(f"Processing {pkg_name}")
            if pkg_name in self.cache.package_map:
                # Skip if output directory already exists
                output_pkg_dir = project.lib_dir / pkg_name
                if output_pkg_dir.exists():
                    logger.warning(f"Output directory already exists: {output_pkg_dir}")
                    continue

                package_file = self.cache.package_map[pkg_name]
                logger.info(f"Extracting {package_file.name}...")
                self._extract_package(package_file, project.lib_dir, pkg_name)
                extracted_packages.append(pkg_name)
                logger.info(f"Extracted {pkg_name}")
            else:
                logger.warning(f"Package not found in cache: {pkg_name}")
                # Attempt to download the missing package
                logger.info(f"Attempting to download missing package: {pkg_name}")

                # Create a temporary dependency object for the missing package
                missing_dep = Dependency(
                    name=pkg_name,
                    version=None,
                    extras=set(),
                    requires=set(),
                )

                # Try to download the missing package
                downloaded_file = self.downloader._download_package(
                    missing_dep,
                    self.cache.cache_dir,
                )
                if downloaded_file:
                    logger.info(f"Successfully downloaded missing package: {pkg_name}")
                    # Now check again if it's in the cache and extract if available
                    if pkg_name in self.cache.package_map:
                        package_file = self.cache.package_map[pkg_name]
                        logger.info(f"Extracting {package_file.name}...")
                        self._extract_package(package_file, project.lib_dir, pkg_name)
                        extracted_packages.append(pkg_name)
                        logger.info(f"Extracted {pkg_name}")
                    else:
                        logger.error(
                            f"Package {pkg_name} still not found in cache after download attempt",
                        )
                else:
                    logger.error(f"Failed to download missing package: {pkg_name}")

        # Calculate final statistics
        total_packages = len(project.dependencies)
        successful_packages = len(extracted_packages)
        failed_packages = total_packages - successful_packages

        logger.info(
            f"Pack complete for {project.name}: {successful_packages}/{total_packages}",
        )
        logger.info(f"{120 * '='}")

        return PackResult(
            success=successful_packages > 0,
            project=project.name,
            total=total_packages,
            successful=successful_packages,
            failed=failed_packages,
            packages_dir=str(project.lib_dir),
            extracted_packages=extracted_packages,
        )

    def _build_and_cache_wheel(self, sdist_file: Path, package_name: str) -> None:
        """Build wheel from sdist file and cache it for faster future access.

        Args:
            sdist_file: Path to sdist file (.tar.gz or .zip)
            package_name: Name of the package
        """
        with tempfile.TemporaryDirectory() as temp_wheel_dir:
            # Use pip wheel to build wheel from sdist
            result = subprocess.run(
                [
                    self.downloader.pip_executable or "pip",
                    "wheel",
                    "--no-deps",
                    "--wheel-dir",
                    temp_wheel_dir,
                    "--no-cache-dir",
                    str(sdist_file),
                ],
                capture_output=True,
                text=True,
                check=False,
            )

            if result.returncode != 0:
                error_msg = result.stderr.strip() if result.stderr else "Unknown error"
                logger.warning(
                    f"Failed to build wheel from sdist for {package_name}: {error_msg}",
                )
                # Log additional diagnostic information
                logger.debug(f"Build command stdout: {result.stdout}")
                logger.debug(f"Build command stderr: {result.stderr}")
                logger.debug(f"Return code: {result.returncode}")

                # Check for common compilation issues
                if "LNK1181" in error_msg or "libpq.lib" in error_msg:
                    logger.warning(
                        f"Compilation failed for {package_name} due to missing libraries. "
                        "This is common for packages requiring native compilation on Windows. "
                        "Consider using pre-compiled wheels or alternative packages.",
                    )
                elif "Microsoft Visual Studio" in error_msg or "compiler" in error_msg.lower():
                    logger.warning(
                        f"Compilation failed for {package_name} due to compiler issues. "
                        "Ensure you have the required build tools installed.",
                    )
                return

            # Find the built wheel file
            wheel_files = list(Path(temp_wheel_dir).glob("*.whl"))
            if wheel_files:
                wheel_file = wheel_files[0]
                # Copy wheel to cache directory
                cache_wheel_path = self.cache.cache_dir / wheel_file.name
                shutil.copy2(wheel_file, cache_wheel_path)

                # Update cache metadata
                self.cache.add_package(package_name, wheel_file)

                logger.info(
                    f"Built and cached wheel: {wheel_file.name} for {package_name}",
                )
            else:
                logger.warning(f"No wheel file was built from sdist for {package_name}")

    def _extract_package(
        self,
        package_file: Path,
        dest_dir: Path,
        package_name: str,
    ) -> None:
        """Extract package file (wheel or sdist) to destination directory with optimization.

        Args:
            package_file: Path to package file (wheel or sdist)
            dest_dir: Destination directory
            package_name: Name of the package being extracted
        """
        logger.info(
            f"Extracting {package_file.name} for package {package_name} to {dest_dir}",
        )

        # Handle sdist files (.tar.gz or .zip) - install using pip, and build wheel for cache
        if package_file.suffix in {".gz", ".zip"}:
            self._handle_sdist_extraction(package_file, dest_dir, package_name)
            return

        # Handle wheel files with optional optimization
        self._handle_wheel_extraction(package_file, dest_dir, package_name)

    def _handle_sdist_extraction(
        self,
        package_file: Path,
        dest_dir: Path,
        package_name: str,
    ) -> None:
        """Handle extraction of sdist files (.tar.gz or .zip)."""
        logger.info(f"Installing sdist file for {package_name} using pip...")

        # Use pip install --target to install sdist to temporary directory
        with tempfile.TemporaryDirectory() as temp_install_dir:
            result = subprocess.run(
                [
                    self.downloader.pip_executable or "pip",
                    "install",
                    "--target",
                    temp_install_dir,
                    "--no-deps",  # Don't install dependencies (we handle them separately)
                    "--no-cache-dir",
                    str(package_file),
                ],
                capture_output=True,
                text=True,
                check=False,
            )

            if result.returncode != 0:
                logger.error(
                    f"Failed to install sdist {package_file.name}: {result.stderr}",
                )
                return

            # Copy installed files to dest_dir, skipping *.dist-info directories
            temp_install_path = Path(temp_install_dir)
            # Pre-compute dist-info suffix
            dist_info_suffix = ".dist-info"

            for item in temp_install_path.iterdir():
                # Skip dist-info directories
                if item.name.endswith(dist_info_suffix):
                    logger.debug(f"Skipping dist-info directory: {item.name}")
                    continue
                dest_path = dest_dir / item.name
                if item.is_dir():
                    if dest_path.exists():
                        shutil.rmtree(dest_path)
                    shutil.copytree(item, dest_path)
                else:
                    shutil.copy2(item, dest_path)

            logger.info(
                f"Installed sdist file for {package_name} to site-packages structure",
            )

        # Build wheel from sdist and cache it for faster future access
        logger.info(f"Building wheel from sdist for {package_name}...")
        self._build_and_cache_wheel(package_file, package_name)

    def _handle_wheel_extraction(
        self,
        package_file: Path,
        dest_dir: Path,
        package_name: str,
    ) -> None:
        """Handle extraction of wheel files with optional optimization."""
        with zipfile.ZipFile(package_file, "r") as zf:
            if self.config.optimize and self.optimization_strategy:
                self._extract_with_optimization(zf, dest_dir, package_name)
            else:
                self._extract_without_optimization(zf, dest_dir, package_name)

    def _extract_with_optimization(
        self,
        zf: zipfile.ZipFile,
        dest_dir: Path,
        package_name: str,
    ) -> None:
        """Extract wheel with optimization strategy applied."""
        extracted_count = 0
        skipped_count = 0

        assert self.optimization_strategy is not None, "Optimization strategy is None"
        should_extract = self.optimization_strategy.should_extract_file

        for file_info in zf.filelist:
            file_path = Path(file_info.filename)

            # Skip dist-info directories
            if LibraryCache._should_skip_dist_info(
                file_path,
            ):  # Use LibraryCache method
                logger.debug(f"Skipping dist-info: {file_info.filename}")
                skipped_count += 1
                continue

            if should_extract(package_name, file_path):
                zf.extract(file_info, dest_dir)
                extracted_count += 1
                logger.debug(f"Extracted {file_path} from {package_name}")
            else:
                skipped_count += 1
                logger.debug(
                    f"Skipped {file_path} from {package_name} (filtered by optimization strategy)",
                )

        logger.info(
            f"Extraction complete for {package_name}: {extracted_count} extracted, {skipped_count} skipped",
        )

    def _extract_without_optimization(
        self,
        zf: zipfile.ZipFile,
        dest_dir: Path,
        package_name: str,
    ) -> None:
        """Extract wheel without optimization, but skip dist-info directories."""
        # Pre-compute the skip function
        should_skip = LibraryCache._should_skip_dist_info  # Use LibraryCache method

        for file_info in zf.filelist:
            file_path = Path(file_info.filename)
            # Skip dist-info directories
            if not should_skip(file_path):
                zf.extract(file_info, dest_dir)
        logger.info(
            f"All files extracted for {package_name} (no optimization applied, dist-info skipped)",
        )

    @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 run(self) -> None:
        """Pack project dependencies from base directory with concurrent processing."""
        t0 = time.perf_counter()
        project_count = self.project_count  # Use cached property

        logger.info(f"Starting to pack {project_count} projects")

        if project_count == 1:
            # Single project: process directly
            project = next(iter(self.projects.values()))
            self.pack_project(project)
        else:
            # Multiple projects: process concurrently
            logger.info(f"Packing {project_count} projects concurrently...")
            with ThreadPoolExecutor(max_workers=self.config.max_workers) as executor:
                futures = [executor.submit(self.pack_project, project) for project in self.projects.values()]

                # Wait for all tasks to complete
                for future in as_completed(futures):
                    try:
                        future.result()
                    except Exception as e:
                        logger.exception(f"Project packing failed: {e}")

        elapsed_time = time.perf_counter() - t0
        logger.info(f"Packed {project_count} projects in {elapsed_time:.2f}s")

        # Log cache statistics after packing
        logger.info(f"Cache statistics: {self.cache.cache_stats}")

    def show_stats(self) -> None:
        """Show statistics about the packing process."""
        logger.info(f"Project count: {self.project_count}")
        logger.info(f"Total dependencies: {len(self.solution.dependencies)}")
        logger.info(f"Working directory size: {self.working_dir_size} bytes")
        logger.info(f"Cache statistics: {self.cache.cache_stats}")
