import asyncio
import aiohttp
from typing import List, Dict, Any, Optional
import json
import time
from concurrent.futures import ThreadPoolExecutor
import re
from dataclasses import dataclass
from urllib.parse import quote
import logging

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


@dataclass
class PackageInfo:
    """
    Comprehensive package information data class.

    Attributes:
            name (str): The name of the package
            version (str): The latest version of the package
            summary (str): Short description of the package
            description (str): Detailed description of the package
            author (str): Package author name
            author_email (str): Package author email
            maintainer (str): Package maintainer name
            maintainer_email (str): Package maintainer email
            home_page (str): Package homepage URL
            project_url (str): Project URL
            license (str): Package license
            requires_python (str): Python version requirements
            download_url (str): Direct download URL
            project_urls (Dict[str, str]): Additional project URLs
            releases (Dict[str, List[Dict]]): Package releases information
            classifiers (List[str]): Package classifiers and categories
            downloads (Dict[str, int]): Download statistics
            score (float): Search relevance score
            last_updated (str): Last update timestamp
    """

    name: str
    version: str
    summary: str = ""
    description: str = ""
    author: str = ""
    author_email: str = ""
    maintainer: str = ""
    maintainer_email: str = ""
    home_page: str = ""
    project_url: str = ""
    license: str = ""
    requires_python: str = ""
    download_url: str = ""
    project_urls: Dict[str, str] = None
    releases: Dict[str, List[Dict]] = None
    classifiers: List[str] = None
    downloads: Dict[str, int] = None
    score: float = 0.0
    last_updated: str = ""

    def __post_init__(self):
        """Initialize mutable default values."""
        if self.project_urls is None:
            self.project_urls = {}
        if self.releases is None:
            self.releases = {}
        if self.classifiers is None:
            self.classifiers = []
        if self.downloads is None:
            self.downloads = {}


class PyPISearch:
    """
     PyPI search utility with multiple search strategies and caching.

    This class provides comprehensive search capabilities for Python packages
    on PyPI using official APIs, web scraping, and multiple fallback strategies.
    It features asynchronous operations, result caching, and detailed package
    information retrieval.

    Features:
            - Asynchronous HTTP requests using aiohttp
            - Multiple search strategies with fallbacks
            - Result caching with TTL
            - Detailed package metadata extraction
            - Download statistics
            - Relevance scoring
            - Rate limiting handling
            - Comprehensive error handling
    """

    def __init__(
        self, cache_ttl: int = 300, max_concurrent: int = 10, timeout: int = 15
    ):
        """
        Initialize the PyPI search utility.

        Args:
                cache_ttl (int): Time-to-live for cache in seconds (default: 300)
                max_concurrent (int): Maximum concurrent requests (default: 10)
                timeout (int): Request timeout in seconds (default: 15)
        """
        self.cache_ttl = cache_ttl
        self.max_concurrent = max_concurrent
        self.timeout = timeout
        self._cache = {}
        self._session = None
        self.rate_limit_delay = 0.1  # Delay between requests to avoid rate limiting

    async def __aenter__(self):
        """Async context manager entry."""
        await self._ensure_session()
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        """Async context manager exit."""
        await self.close()

    async def _ensure_session(self):
        """Ensure aiohttp session is available."""
        if self._session is None:
            timeout = aiohttp.ClientTimeout(total=self.timeout)
            self._session = aiohttp.ClientSession(timeout=timeout)

    async def close(self):
        """Close the aiohttp session."""
        if self._session:
            await self._session.close()
            self._session = None

    def _get_cache_key(self, query: str, search_type: str) -> str:
        """Generate cache key for query and search type."""
        return f"{search_type}:{query.lower().strip()}"

    def _is_cache_valid(self, cache_key: str) -> bool:
        """Check if cache entry is still valid."""
        if cache_key in self._cache:
            timestamp, _ = self._cache[cache_key]
            return (time.time() - timestamp) < self.cache_ttl
        return False

    async def search(
        self, query: str, max_results: int = 20, include_details: bool = True
    ) -> List[PackageInfo]:
        """
        Perform comprehensive PyPI package search with multiple strategies.

        This method employs a multi-tier search strategy:
        1. Primary: Official PyPI JSON API
        2. Secondary: PyPI search API with web scraping
        3. Fallback: Simple index scanning

        Args:
                query (str): Search query string
                max_results (int): Maximum number of results to return (default: 20)
                include_details (bool): Whether to fetch detailed package information (default: True)

        Returns:
                List[PackageInfo]: List of package information objects sorted by relevance

        Raises:
                aiohttp.ClientError: If network requests fail
                ValueError: If query is empty or invalid

        Example:
                >>> async with PyPISearch() as searcher:
                >>>	 results = await searcher.search("requests", max_results=10)
                >>>	 for package in results:
                >>>		 print(f"{package.name} {package.version}: {package.summary}")
        """
        if not query or not query.strip():
            raise ValueError("Query cannot be empty")

        query = query.strip()
        cache_key = self._get_cache_key(query, "search")

        # Check cache first
        if self._is_cache_valid(cache_key):
            logger.info(f"Returning cached results for query: {query}")
            return self._cache[cache_key][1][:max_results]

        await self._ensure_session()
        await asyncio.sleep(self.rate_limit_delay)  # Rate limiting

        packages = []

        try:
            # Strategy 1: Official PyPI JSON API search
            packages = await self._search_primary(query, max_results)

            if not packages:
                # Strategy 2: Web scraping fallback
                packages = await self._search_secondary(query, max_results)

        except Exception as e:
            logger.error(f"Search failed for query '{query}': {e}")
            # Final fallback strategy
            packages = await self._search_fallback(query, max_results)

        # Fetch detailed information if requested
        if include_details and packages:
            packages = await self._enrich_package_details(packages)

        # Sort by relevance score
        packages.sort(key=lambda x: x.score, reverse=True)

        # Cache the results
        self._cache[cache_key] = (time.time(), packages)

        return packages[:max_results]

    async def _search_primary(self, query: str, max_results: int) -> List[PackageInfo]:
        """
        Primary search using PyPI's official JSON API.

        Args:
                query (str): Search query
                max_results (int): Maximum results to return

        Returns:
                List[PackageInfo]: List of package information
        """
        try:
            encoded_query = quote(query)
            url = f"https://pypi.org/pypi?%3Aaction=search&term={encoded_query}&submit=search"

            async with self._session.get(url) as response:
                if response.status == 200:
                    text = await response.text()
                    return self._parse_primary_results(text, query, max_results)

        except Exception as e:
            logger.warning(f"Primary search failed: {e}")

        return []

    def _parse_primary_results(
        self, html: str, query: str, max_results: int
    ) -> List[PackageInfo]:
        """
        Parse HTML response from primary search.

        Args:
                html (str): HTML content
                query (str): Original search query
                max_results (int): Maximum results to return

        Returns:
                List[PackageInfo]: Parsed package information
        """
        packages = []
        pattern = r'<a class="package-snippet" href="(/project/[^"]+)">\s*<h3 class="package-snippet__title">\s*<span class="package-snippet__name">([^<]+)</span>\s*<span class="package-snippet__version">([^<]+)</span>\s*</h3>\s*<p class="package-snippet__description">([^<]*)</p>'

        matches = re.findall(pattern, html, re.DOTALL)

        for match in matches[:max_results]:
            project_url, name, version, summary = match
            score = self._calculate_relevance_score(query, name, summary)

            package = PackageInfo(
                name=name.strip(),
                version=version.strip(),
                summary=summary.strip(),
                score=score,
            )
            packages.append(package)

        return packages

    async def _search_secondary(
        self, query: str, max_results: int
    ) -> List[PackageInfo]:
        """
        Secondary search using PyPI's simple API.

        Args:
                query (str): Search query
                max_results (int): Maximum results to return

        Returns:
                List[PackageInfo]: List of package information
        """
        try:
            url = "https://pypi.org/simple/"

            async with self._session.get(url) as response:
                if response.status == 200:
                    text = await response.text()
                    return self._parse_secondary_results(text, query, max_results)

        except Exception as e:
            logger.warning(f"Secondary search failed: {e}")

        return []

    def _parse_secondary_results(
        self, html: str, query: str, max_results: int
    ) -> List[PackageInfo]:
        """
        Parse simple index HTML for package names.

        Args:
                html (str): HTML content
                query (str): Original search query
                max_results (int): Maximum results to return

        Returns:
                List[PackageInfo]: Parsed package information
        """
        packages = []
        pattern = r"<a[^>]*>([^<]+)</a>"

        matches = re.findall(pattern, html)
        query_lower = query.lower()

        for match in matches:
            name = match.strip()
            if query_lower in name.lower():
                score = self._calculate_relevance_score(query, name, "")

                package = PackageInfo(
                    name=name, version="unknown", summary="", score=score
                )
                packages.append(package)

                if len(packages) >= max_results:
                    break

        return packages

    async def _search_fallback(self, query: str, max_results: int) -> List[PackageInfo]:
        """
        Final fallback search strategy.

        Args:
                query (str): Search query
                max_results (int): Maximum results to return

        Returns:
                List[PackageInfo]: List of package information
        """
        try:
            # Try direct package lookup
            package = await self.get_package_details(query)
            if package:
                package.score = 1.0
                return [package]

        except Exception as e:
            logger.warning(f"Fallback search failed: {e}")

        return []

    async def get_package_details(self, package_name: str) -> Optional[PackageInfo]:
        """
        Get detailed information for a specific package.

        Args:
                package_name (str): Exact package name

        Returns:
                Optional[PackageInfo]: Detailed package information or None if not found

        Example:
                >>> package = await searcher.get_package_details("requests")
                >>> print(f"Latest version: {package.version}")
                >>> print(f"Author: {package.author}")
        """
        cache_key = self._get_cache_key(package_name, "details")

        if self._is_cache_valid(cache_key):
            return self._cache[cache_key][1]

        await self._ensure_session()

        try:
            url = f"https://pypi.org/pypi/{package_name}/json"

            async with self._session.get(url) as response:
                if response.status == 200:
                    data = await response.json()
                    package = self._parse_package_details(data)
                    self._cache[cache_key] = (time.time(), package)
                    return package

        except Exception as e:
            logger.error(f"Failed to get details for {package_name}: {e}")

        return None

    def _parse_package_details(self, data: Dict[str, Any]) -> PackageInfo:
        """
        Parse detailed package information from JSON API response.

        Args:
                data (Dict[str, Any]): JSON API response data

        Returns:
                PackageInfo: Parsed package information
        """
        info = data["info"]
        releases = data.get("releases", {})

        # Calculate download statistics
        downloads = self._calculate_download_stats(releases)

        package = PackageInfo(
            name=info.get("name", ""),
            version=info.get("version", ""),
            summary=info.get("summary", ""),
            description=info.get("description", ""),
            author=info.get("author", ""),
            author_email=info.get("author_email", ""),
            maintainer=info.get("maintainer", ""),
            maintainer_email=info.get("maintainer_email", ""),
            home_page=info.get("home_page", ""),
            project_url=info.get("project_url", ""),
            license=info.get("license", ""),
            requires_python=info.get("requires_python", ""),
            download_url=info.get("download_url", ""),
            project_urls=info.get("project_urls", {}),
            releases=releases,
            classifiers=info.get("classifiers", []),
            downloads=downloads,
            last_updated=info.get(
                "release_url", ""
            ),  # Using release_url as timestamp proxy
        )

        return package

    def _calculate_download_stats(
        self, releases: Dict[str, List[Dict]]
    ) -> Dict[str, int]:
        """
        Calculate download statistics from release data.

        Args:
                releases (Dict[str, List[Dict]]): Releases information

        Returns:
                Dict[str, int]: Download statistics
        """
        stats = {"total_downloads": 0, "release_count": len(releases), "file_count": 0}

        for release_files in releases.values():
            stats["file_count"] += len(release_files)
            for file_info in release_files:
                stats["total_downloads"] += file_info.get("downloads", 0)

        return stats

    async def _enrich_package_details(
        self, packages: List[PackageInfo]
    ) -> List[PackageInfo]:
        """
        Enrich package list with detailed information using concurrent requests.

        Args:
                packages (List[PackageInfo]): List of basic package information

        Returns:
                List[PackageInfo]: List of enriched package information
        """
        semaphore = asyncio.Semaphore(self.max_concurrent)

        async def get_details(package: PackageInfo) -> PackageInfo:
            async with semaphore:
                try:
                    detailed_package = await self.get_package_details(package.name)
                    if detailed_package:
                        # Preserve the original score
                        detailed_package.score = package.score
                        return detailed_package
                except Exception as e:
                    logger.warning(f"Failed to enrich {package.name}: {e}")
                return package

        tasks = [get_details(package) for package in packages]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        # Filter out exceptions
        enriched_packages = []
        for result in results:
            if isinstance(result, PackageInfo):
                enriched_packages.append(result)
            elif isinstance(result, Exception):
                logger.warning(f"Enrichment task failed: {result}")

        return enriched_packages

    def _calculate_relevance_score(self, query: str, name: str, summary: str) -> float:
        """
        Calculate relevance score for search results.

        Args:
                query (str): Search query
                name (str): Package name
                summary (str): Package summary

        Returns:
                float: Relevance score between 0.0 and 1.0
        """
        query_lower = query.lower()
        name_lower = name.lower()
        summary_lower = summary.lower()

        score = 0.0

        # Exact name match gets highest score
        if query_lower == name_lower:
            score += 0.8
        # Query contains package name or vice versa
        elif query_lower in name_lower or name_lower in query_lower:
            score += 0.6
        # Name starts with query
        elif name_lower.startswith(query_lower):
            score += 0.5
        # Query in summary
        elif query_lower in summary_lower:
            score += 0.3
        # Partial matches
        else:
            # Check for word matches
            query_words = set(query_lower.split())
            name_words = set(name_lower.split())
            summary_words = set(summary_lower.split())

            name_matches = len(query_words.intersection(name_words))
            summary_matches = len(query_words.intersection(summary_words))

            score += (name_matches * 0.2) + (summary_matches * 0.1)

        return min(score, 1.0)

    async def batch_search(
        self, queries: List[str], max_results: int = 10
    ) -> Dict[str, List[PackageInfo]]:
        """
        Perform multiple searches concurrently.

        Args:
                queries (List[str]): List of search queries
                max_results (int): Maximum results per query

        Returns:
                Dict[str, List[PackageInfo]]: Dictionary mapping queries to results

        Example:
                >>> results = await searcher.batch_search(["requests", "numpy", "pandas"])
                >>> for query, packages in results.items():
                >>>	 print(f"Results for {query}: {len(packages)} packages")
        """
        tasks = {}
        for query in queries:
            tasks[query] = self.search(query, max_results)

        results = {}
        for query, task in tasks.items():
            try:
                results[query] = await task
            except Exception as e:
                logger.error(f"Batch search failed for {query}: {e}")
                results[query] = []

        return results

    def clear_cache(self):
        """Clear all cached results."""
        self._cache.clear()
        logger.info("Cache cleared")


# Convenience function for simple usage
async def search_package(
    query: str, max_results: int = 20, include_details: bool = True, timeout: int = 15
) -> List[PackageInfo]:
    """
     PyPI search with comprehensive package information.

    This is a convenience function that wraps the PyPISearch class for simple usage.
    It provides async search capabilities with detailed package metadata, caching,
    and multiple fallback strategies.

    Args:
            query (str): Search query string
            max_results (int): Maximum number of results to return (default: 20)
            include_details (bool): Whether to fetch detailed package information (default: True)
            timeout (int): Request timeout in seconds (default: 15)

    Returns:
            List[PackageInfo]: List of package information objects sorted by relevance

    Raises:
            aiohttp.ClientError: If network requests fail
            ValueError: If query is empty or invalid

    Example:
            >>> import asyncio
            >>>
            >>> async def main():
            >>>	 results = await search_package("web framework", max_results=5)
            >>>	 for package in results:
            >>>		 print(f"{package.name} v{package.version}")
            >>>		 print(f"   {package.summary}")
            >>>		 print(f"   Score: {package.score:.2f}")
            >>>		 print(f"   Downloads: {package.downloads.get('total_downloads', 0):,}")
            >>>		 print()
            >>>
            >>> asyncio.run(main())
    """
    async with PyPISearch(timeout=timeout) as searcher:
        return await searcher.search(query, max_results, include_details)


# Synchronous wrapper for convenience
def search_sync(
    query: str, max_results: int = 20, include_details: bool = True, timeout: int = 15
) -> List[PackageInfo]:
    """
    Synchronous wrapper for  PyPI search_package.

    Note: This function creates a new event loop and should not be used
    in async contexts. For async code, use search_pypi_ instead.

    Args:
            query (str): Search query string
            max_results (int): Maximum number of results to return (default: 20)
            include_details (bool): Whether to fetch detailed package information (default: True)
            timeout (int): Request timeout in seconds (default: 15)

    Returns:
            List[PackageInfo]: List of package information objects sorted by relevance
    """
    return asyncio.run(search_package(query, max_results, include_details, timeout))


# Example usage and testing
async def demo():
    """Demonstrate the  PyPI search capabilities."""
    print("PyPI Search Demo")
    print("=" * 50)

    # Search for packages
    query = "importer-core"
    results = await search_package(query, max_results=5, include_details=True)

    print(f"Results for '{query}':")
    print()

    for i, package in enumerate(results, 1):
        print(f"{i}. {package.name} v{package.version} (Score: {package.score:.2f})")
        print(f"   {package.summary}")
        if package.author:
            print(f"   Author: {package.author}")
        if package.downloads.get("total_downloads"):
            print(f"   Total Downloads: {abs(package.downloads['total_downloads'])}")
        if package.requires_python:
            print(f"   Python: {package.requires_python}")
        if package.license:
            print(f"   License: {package.license}")
        print()


if __name__ == "__main__":
    # Run the demo
    asyncio.run(demo())
