"""Parse pyproject.toml files in directory, supports multiple projects.

This module provides classes and functions for parsing pyproject.toml files
and extracting project metadata. It offers two main classes:

- Project: Represents a single Python project with its metadata and dependencies
- Solution: Represents a collection of projects found in a directory tree

The module supports loading from TOML files, JSON files, and directory scanning,
with caching capabilities for improved performance.
"""

from __future__ import annotations

import argparse
import datetime
import json
import logging
import platform
import re
import sys
import time
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from re import Pattern
from typing import Any, Final

if sys.version_info >= (3, 11):
    import tomllib
else:
    import tomli as tomllib  # type: ignore

__all__ = ["Project", "Solution"]


logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
cwd = Path.cwd()
is_windows = platform.system() == "Windows"

# Precompiled regex for dependency name extraction (optimization)
_DEP_NAME_PATTERN: Final[Pattern[str]] = re.compile(r"^([a-zA-Z0-9._-]+)")
_EXTRA_PATTERN: Final[Pattern[str]] = re.compile(r"\[([^\]]+)\]")
_VERSION_PATTERN: Final[Pattern[str]] = re.compile(r"[<>=!~].*$")

# Qt-related keywords and dependencies for faster detection
_QT_DEPENDENCIES: Final[frozenset[str]] = frozenset((
    "Qt",
    "PySide",
    "PyQt",
    "PySide2",
    "PySide6",
    "PyQt5",
    "PyQt6",
    "Qt5",
    "Qt6",
))

# GUI-related keywords for faster detection
_GUI_KEYWORDS: Final[frozenset[str]] = frozenset(("gui", "desktop"))

# Required attributes for project validation (module-level constant for performance)
_REQUIRED_ATTRS: Final[frozenset[str]] = frozenset(("name", "version", "description"))


@dataclass(frozen=True)
class Dependency:
    """Represents a Python package dependency."""

    name: str = ""
    version: str | None = None
    extras: set[str] = field(default_factory=set)
    requires: set[str] = field(default_factory=set)

    @staticmethod
    def from_str(dep_str: str) -> Dependency:
        """Create a Dependency instance from a dependency string.

        Args:
            dep_str (str): The dependency string in the format "name[extras]version".

        Returns:
            Dependency: The created Dependency instance.
        """
        # Parse the dependency string to extract name, extras, and version
        name = ""
        version = None  # Use None initially, will be set if version is found
        extras = set()

        # First, extract extras if present (text within square brackets)
        extras_match = _EXTRA_PATTERN.search(dep_str)
        if extras_match:
            extras_str = extras_match.group(1)
            extras = {extra.strip() for extra in extras_str.split(",")}
            # Remove the extras part from the string for further processing
            dep_str = dep_str[: extras_match.start()] + dep_str[extras_match.end() :]

        # Extract version specifier (starts with comparison operators like >=, <=, ==, etc.)
        version_match = _VERSION_PATTERN.search(dep_str)
        if version_match:
            version = version_match.group()
            dep_str = dep_str[: version_match.start()].strip()

        # Remaining part is the name
        name = dep_str.strip()

        return Dependency(name=name, version=version, extras=extras, requires=set())

    def __post_init__(self):
        # Normalize the name after initialization
        # This ensures that names like "Requests-OAuthLib" become "requests_oauthlib"
        object.__setattr__(self, "name", self.name.lower().replace("-", "_"))

    @cached_property
    def normalized_name(self) -> str:
        """Return the normalized name of the dependency (same as name since it's already normalized)."""
        return self.name

    def __str__(self) -> str:
        """String representation of dependency."""
        # Pre-cache version to avoid repeated evaluation
        version_str = self.version or ""
        if self.extras:
            # Pre-cache sorted extras to avoid repeated sorting
            sorted_extras = ",".join(sorted(self.extras))
            return f"{self.name}[{sorted_extras}]{version_str}"
        return f"{self.name}{version_str}"

    def __hash__(self) -> int:
        return hash((self.name, self.version, frozenset(self.extras)))


@dataclass(frozen=True)
class Project:
    """Represents a single Python project parsed from a pyproject.toml file.

    This class encapsulates all metadata and configuration information from a Python project's
    pyproject.toml file. It provides convenient access to project properties and utility methods
    for analyzing project characteristics such as dependencies and GUI frameworks used.

    Attributes:
        name: The name of the Python package
        version: The version string of the package
        description: Brief description of the package
        readme: Path to the project's README file
        requires_python: Python version requirement (e.g., ">=3.8")
        dependencies: List of required dependencies
        optional_dependencies: Dictionary of optional dependency groups
        scripts: Dictionary of console script entry points
        entry_points: Dictionary of other entry points
        authors: List of author information
        license: License information
        keywords: List of keywords/tags for the package
        classifiers: List of trove classifiers
        urls: Dictionary of project URLs (homepage, repository, etc.)
        build_backend: Build backend system used (e.g., "setuptools.build_meta")
        requires: List of build system requirements
        toml_path: Path to the pyproject.toml file
        solution_root_dir: Root directory of the solution (for multi-project setups)
    """

    name: str
    version: str
    description: str
    readme: str
    requires_python: str
    dependencies: list[str]
    optional_dependencies: dict[str, list[str]]
    scripts: dict[str, str]
    entry_points: dict[str, Any]
    authors: list[dict[str, Any]]
    license: str
    keywords: list[str]
    classifiers: list[str]
    urls: dict[str, str]
    build_backend: str
    requires: list[str]
    toml_path: Path
    solution_root_dir: Path | None = None

    @cached_property
    def min_python_version(self) -> str | None:
        """Extract the minimum Python version from requires_python.

        Parses the requires_python string to extract the minimum version requirement.
        Supports formats like ">=3.8", ">3.7", "~=3.9", etc. Returns None if no
        minimum version is specified or if the format is unrecognized.

        Returns:
            The minimum Python version as a string, or None if not found.
        """
        if not self.requires_python:
            return None

        # Pattern to match minimum Python version requirements
        # Examples: >=3.8, >3.7, ~=3.9, ==3.8.*, etc.
        patterns = [
            r">=(\d+\.\d+(?:\.\d+)?)",  # >=3.8, >=3.8.1
            r">(\d+\.\d+(?:\.\d+)?)",  # >3.7
            r"~=?(\d+\.\d+(?:\.\d+)?)",  # ~=3.9, ~3.9
            r"==?(\d+\.\d+(?:\.\d+)?)(?:\.\*)?",  # ==3.8, ==3.8.*, =3.8
        ]

        for pattern in patterns:
            match = re.search(pattern, self.requires_python)
            if match:
                return match.group(1)

        return None

    @cached_property
    def root_dir(self) -> Path:
        """Return the root directory of the project."""
        if not self.toml_path.is_file():
            # Return current directory if toml_path is not set or invalid
            # This can happen when Project is created with default values
            return Path()

        return self.toml_path.parent

    @cached_property
    def dist_dir(self) -> Path:
        """Return the distribution directory of the project.

        In multi-project solutions, returns solution_root_dir/dist.
        In single-project setups, returns project_root_dir/dist.
        """
        if self.solution_root_dir:
            dist_dir = self.solution_root_dir / "dist"
        else:
            dist_dir = self.root_dir / "dist"
        dist_dir.mkdir(parents=True, exist_ok=True)
        return dist_dir

    @cached_property
    def extension(self) -> str:
        return ".exe" if is_windows else ""

    @cached_property
    def exe_name(self) -> str:
        """Return the executable name of the project."""
        return f"{self.name}{self.extension}"

    @cached_property
    def exe_path(self) -> Path:
        """Return the executable path of the project."""
        return self.dist_dir / f"{self.name}{self.extension}"

    @cached_property
    def runtime_dir(self) -> Path:
        """Return the runtime directory of the project."""
        runtime_dir = self.dist_dir / "runtime"
        runtime_dir.mkdir(parents=True, exist_ok=True)
        return runtime_dir

    @cached_property
    def lib_dir(self) -> Path:
        """Return the lib directory of the project."""
        lib_dir = self.dist_dir / "site-packages"
        lib_dir.mkdir(parents=True, exist_ok=True)
        return lib_dir

    @cached_property
    def normalized_name(self) -> str:
        return self.name.replace("-", "_")

    @cached_property
    def converted_dependencies(self) -> set[Dependency]:
        return {Dependency.from_str(dep) for dep in self.dependencies}

    @cached_property
    def dep_names(self) -> set[str]:
        """Extract normalized dependency names from the dependencies list.

        This method parses dependency strings to extract just the package names,
        removing version specifiers, extras, and environment markers.
        Handles complex dependency formats like: package[extra]>=1.0; python_version>="3.8"

        Returns:
            Set of normalized dependency package names.
        """
        return {
            match.group(1)
            if (match := _DEP_NAME_PATTERN.match(main_part))
            else main_part
            for dep in self.dependencies
            if (main_part := dep.split(";")[0].strip())
        }

    @cached_property
    def qt_deps(self) -> set[str]:
        """Cached intersection of dependencies with Qt packages.

        Returns:
            Set of Qt-related dependency names found in the project.
        """
        # Pre-cache the sets to avoid attribute lookup overhead
        dep_names_set = self.dep_names
        qt_deps_set = _QT_DEPENDENCIES
        return dep_names_set & qt_deps_set

    @cached_property
    def has_qt(self) -> bool:
        return bool(self.qt_deps)

    @cached_property
    def qt_libname(self) -> str | None:
        return next(iter(self.qt_deps), None)

    @cached_property
    def is_gui(self) -> bool:
        # Use set intersection for O(1) lookup instead of O(n) with any()
        # Also consider projects with Qt dependencies as GUI applications
        return bool(set(self.keywords) & _GUI_KEYWORDS) or self.has_qt

    @cached_property
    def loader_type(self) -> str:
        return "gui" if self.is_gui else "console"

    def __repr__(self):
        return f"<Project {self.name} v{self.version} py{self.requires_python} deps{self.dependencies}>"

    @classmethod
    def from_toml_file(cls, toml_file: Path) -> Project:
        """Create a Project instance by parsing a pyproject.toml file.

        Args:
            toml_file: Path to the pyproject.toml file to parse

        Returns:
            A Project instance containing the parsed project data.
            Returns an empty Project if the file doesn't exist or is invalid.
        """
        if not toml_file.is_file():
            logger.error(f"{toml_file} does not exist")
            return Project._from_empty_dict()

        try:
            with open(toml_file, "rb") as f:
                data = tomllib.load(f)
        except Exception as e:
            logger.error(f"Error parsing {toml_file}: {e}")
            return Project._from_empty_dict()

        if "project" not in data:
            logger.error(f"No project section in {toml_file}")
            return Project._from_empty_dict()

        if "name" not in data["project"]:
            logger.error(f"No name in project section of {toml_file}")
            return Project._from_empty_dict()

        # Extract project data and build system data separately for performance
        project_data = data["project"]
        build_system_data = data.get("build-system", {})

        # Merge data efficiently
        merged_data = project_data.copy()  # Start with project data

        # Handle TOML field names that use hyphens but Python attributes use underscores
        if "requires-python" in merged_data:
            merged_data["requires_python"] = merged_data.pop("requires-python")

        merged_data.update({"toml_path": toml_file})
        merged_data.update(build_system_data)  # Add build system data

        return Project._from_dict(merged_data)

    def pack_source(self):
        """Pack source code and resources to dist/src directory."""
        pass

    def pack_embed_python(self):
        """Pack Python runtime to dist/runtime directory."""
        pass

    @cached_property
    def raw_data(self) -> dict[str, Any]:
        """Convert the Project instance to a dictionary for JSON serialization.

        Returns:
            Dictionary representation of the Project instance suitable for JSON serialization.
        """
        return {
            "name": self.name,
            "version": self.version,
            "description": self.description,
            "readme": self.readme,
            "requires_python": self.requires_python,
            "dependencies": self.dependencies,
            "optional_dependencies": self.optional_dependencies,
            "scripts": self.scripts,
            "entry_points": self.entry_points,
            "authors": self.authors,
            "license": self.license,
            "keywords": self.keywords,
            "classifiers": self.classifiers,
            "urls": self.urls,
            # build-system data
            "build_backend": self.build_backend,
            "requires": self.requires,
        }

    @classmethod
    def _from_empty_dict(cls) -> Project:
        """Create a Project instance from an empty dictionary."""
        return cls._from_dict({})

    @classmethod
    def _from_dict(cls, data: dict[str, Any]) -> Project:
        """Create a Project instance from a dictionary of project attributes.

        Args:
            data: Dictionary containing project attributes

        Returns:
            A Project instance initialized with the provided data.
        """
        return cls(
            name=data.get("name", ""),
            version=data.get("version", ""),
            description=data.get("description", ""),
            readme=data.get("readme", ""),
            requires_python=data.get("requires_python", ""),
            dependencies=data.get("dependencies", []),
            optional_dependencies=data.get("optional_dependencies", {}),
            scripts=data.get("scripts", {}),
            entry_points=data.get("entry_points", {}),
            authors=data.get("authors", []),
            license=data.get("license", ""),
            keywords=data.get("keywords", []),
            classifiers=data.get("classifiers", []),
            urls=data.get("urls", {}),
            build_backend=data.get("build_backend", ""),
            requires=data.get("requires", []),
            toml_path=data.get("toml_path", Path("")),
            solution_root_dir=data.get("solution_root_dir"),
        )


@dataclass(frozen=True)
class Solution:
    """Represents a collection of Python projects found in a directory tree.

    This class manages multiple Project instances discovered by scanning a directory
    for pyproject.toml files. It provides methods to load projects from various sources
    (TOML files, JSON files, or directory scans) and handles project data persistence
    by saving/loading to/from projects.json files.

    Attributes:
        root_dir: The root directory where project scanning originated
        projects: Dictionary mapping project names to their Project instances
        start_time: Float representing the start time for performance measurement
        time_stamp: Datetime object representing when the solution was created
    """

    root_dir: Path
    projects: dict[str, Project]
    update: bool = False
    start_time: float = field(default_factory=time.perf_counter)
    time_stamp: datetime.datetime = field(default_factory=datetime.datetime.now)

    def __repr__(self):
        return (
            f"<Solution(\n"
            f"  root_dir={self.root_dir!r},\n"
            f"  projects: {len(self.projects)},\n"
            f"  update={self.update!r},\n"
            f"  time_used={self.elapsed_time:.4f}s,\n"
            f"  timestamp={self.time_stamp!r}\n"
            f")>"
        )

    def __post_init__(self):
        # Only log brief summary to avoid overly verbose output
        logger.info(
            f"Solution: {len(self.projects)} projects from {self.root_dir}, "
            f"created in {self.elapsed_time:.4f}s at {self.time_stamp:%Y-%m-%d %H:%M:%S}"
        )
        self._write_project_json()

    @cached_property
    def elapsed_time(self) -> float:
        """Calculate and cache the elapsed time since start."""
        return time.perf_counter() - self.start_time

    @cached_property
    def dependencies(self) -> set[str]:
        """Get a set of all dependencies for all projects in the solution.

        Returns:
            Set of dependency package names.
        """
        # Use set comprehension for better performance
        return {
            dep for project in self.projects.values() for dep in project.dependencies
        }

    @cached_property
    def json_file(self) -> Path:
        """Path to the cache file where project data is stored.

        Returns:
            Path to projects.json file in the root directory.
        """
        return self.root_dir / "projects.json"

    def find_matching_projects(self, pattern: str) -> list[str]:
        """Find all projects matching the given pattern (case-insensitive).

        Args:
            pattern: Pattern to match (substring, case-insensitive)

        Returns:
            List of matching project names
        """
        if not pattern:
            return []

        lower_pattern = pattern.lower()
        return [name for name in self.projects if lower_pattern in name.lower()]

    def resolve_project_name(self, project_name: str | None) -> str | None:
        """Resolve project name with fuzzy matching support.

        Resolution strategy:
        1. If project_name is None: auto-select if only one project exists
        2. Exact match: return if project name exists
        3. Fuzzy match: find projects containing the given substring (case-insensitive)
        4. If multiple fuzzy matches: return None (ambiguous)

        Args:
            project_name: Project name to resolve, or None for auto-selection

        Returns:
            Resolved project name, or None if resolution fails

        Examples:
            >>> solution.resolve_project_name(None)  # Auto-select if single project
            'myproject'
            >>> solution.resolve_project_name('docscan')  # Exact match
            'docscan'
            >>> solution.resolve_project_name('doc')  # Fuzzy match
            'docscan'
            >>> solution.resolve_project_name('scan')  # Fuzzy match
            'docscan'
        """
        # Auto-select if only one project
        if not project_name:
            if len(self.projects) == 1:
                return next(iter(self.projects.keys()))
            return None

        # Exact match (case-sensitive)
        if project_name in self.projects:
            return project_name

        # Fuzzy match (case-insensitive substring matching)
        lower_name = project_name.lower()
        matches = [name for name in self.projects if lower_name in name.lower()]

        if len(matches) == 1:
            return matches[0]
        elif len(matches) > 1:
            # Multiple matches found - ambiguous
            return None

        # No match found
        return None

    @classmethod
    def from_toml_files(
        cls, root_dir: Path, toml_files: list[Path], update: bool = False
    ) -> Solution:
        """Create a Solution instance by parsing multiple pyproject.toml files.

        Args:
            root_dir: Root directory where the projects are located
            toml_files: List of paths to pyproject.toml files to parse

        Returns:
            A Solution instance containing all parsed projects.
        """
        projects: dict[str, Project] = {}
        for toml_file in toml_files:
            if not toml_file.is_file():
                logger.warning(f"Warning: {toml_file} is not a file")
                continue
            project = Project.from_toml_file(toml_file)
            if not project.name:
                logger.warning(
                    f"Warning: {toml_file} does not contain project information"
                )
                continue

            # For multi-project solutions, set solution_root_dir
            # Create new Project with solution_root_dir set
            if len(toml_files) > 1:
                project = Project(
                    name=project.name,
                    version=project.version,
                    description=project.description,
                    readme=project.readme,
                    requires_python=project.requires_python,
                    dependencies=project.dependencies,
                    optional_dependencies=project.optional_dependencies,
                    scripts=project.scripts,
                    entry_points=project.entry_points,
                    authors=project.authors,
                    license=project.license,
                    keywords=project.keywords,
                    classifiers=project.classifiers,
                    urls=project.urls,
                    build_backend=project.build_backend,
                    requires=project.requires,
                    toml_path=project.toml_path,
                    solution_root_dir=root_dir,
                )

            projects[project.name] = project

        return cls(root_dir=root_dir, projects=projects, update=update)

    @classmethod
    def from_json_data(
        cls, root_dir: Path, json_data: dict[str, Any], update: bool = False
    ) -> Solution:
        """Create a Solution instance from JSON data.

        Args:
            root_dir: Root directory for the project collection
            json_data: Dictionary containing project data in JSON format

        Returns:
            A Solution instance containing projects parsed from the JSON data.
        """
        projects = {}

        try:
            for key, value in json_data.items():
                if isinstance(value, dict):
                    # Check if the value has a "project" section (from pyproject.toml parsing)
                    if "project" in value:
                        project_data = value.get("project", {})
                        projects[key] = Project._from_dict(project_data)
                    else:
                        # Check if the value contains direct project attributes
                        # Use set intersection with module-level constant for faster attribute checking
                        if value.keys() & _REQUIRED_ATTRS:
                            projects[key] = Project._from_dict(value)
                        else:
                            # No project section or recognizable project attributes found
                            logger.warning(f"No project information found in {key}")
                            projects[key] = Project._from_empty_dict()
                else:
                    projects[key] = value
        except (TypeError, ValueError) as e:
            logger.error(f"Error loading project data from JSON data: {e}")
        except Exception as e:
            logger.error(f"Unknown error loading project data from JSON data: {e}")

        return cls(root_dir=root_dir, projects=projects, update=update)

    @classmethod
    def from_json_file(cls, json_file: Path, update: bool = False) -> Solution:
        """Create a Solution instance from a JSON file.

        Args:
            json_file: Path to the JSON file containing project data
            update: If True, forces re-parsing even if cache exists

        Returns:
            A Solution instance containing projects parsed from the JSON file.
        """
        if not json_file.is_file():
            logger.error(f"Error: {json_file} is not a file")
            return cls(root_dir=json_file.parent, projects={})

        try:
            logger.debug(f"Loading project data from {json_file}...")
            with json_file.open("r", encoding="utf-8") as f:
                loaded_data = json.load(f)
            logger.debug(f"\t - Loaded project data from {json_file}")
            return cls.from_json_data(json_file.parent, loaded_data, update=update)
        except (OSError, json.JSONDecodeError, KeyError) as e:
            logger.error(f"Error loading project data from {json_file}: {e}")
            return cls(root_dir=json_file.parent, projects={})
        except Exception as e:
            logger.error(f"Unknown error loading project data from {json_file}: {e}")
            return cls(root_dir=json_file.parent, projects={})

    @classmethod
    def from_directory(cls, root_dir: Path, update: bool = False) -> Solution:
        """Create a Solution instance by scanning a directory for pyproject.toml files.

        This method recursively searches the given directory for pyproject.toml files,
        parses each one, and creates Project instances from them. If a projects.json
        cache file exists and update is False, it will load from the cache instead.

        Args:
            root_dir: Directory to scan for pyproject.toml files
            update: If True, forces re-parsing even if cache exists

        Returns:
            A Solution instance containing all discovered projects.
        """
        if not root_dir.is_dir():
            logger.error(f"Error: {root_dir} is not a directory")
            return cls(root_dir=root_dir, projects={})

        # Use walrus operator to avoid intermediate variable
        if not update and (project_json := root_dir / "projects.json").is_file():
            return cls.from_json_file(project_json, update=update)

        logger.debug(f"Parsing pyproject.toml in {root_dir}...")
        toml_files = list(root_dir.rglob("pyproject.toml"))
        return cls.from_toml_files(root_dir, toml_files, update=update)

    def _write_project_json(self):
        """Write the project data to a projects.json file for caching.

        This method serializes the project data to JSON format and saves it
        to a cache file named projects.json in the root directory. This enables
        faster loading on subsequent runs unless the update flag is set.

        Args:
            update: If True, forces writing even if the file already exists
        """
        # Cache json_file reference to avoid repeated cached_property access
        json_file = self.json_file
        if json_file.exists() and not self.update:
            logger.info(
                f"\t - Skip write project data file {json_file}, already exists"
            )
            return

        try:
            # Pre-cache raw_data access to avoid repeated property access
            serializable_data = {
                key: project_data.raw_data
                if isinstance(project_data, Project)
                else project_data
                for key, project_data in self.projects.items()
            }

            with json_file.open("w", encoding="utf-8") as f:
                json.dump(serializable_data, f, indent=2, ensure_ascii=False)
            logger.info(f"Output written to {json_file}")
        except (OSError, json.JSONDecodeError, KeyError) as e:
            logger.error(f"Error writing output to {json_file}: {e}")
        except Exception as e:
            logger.error(f"Unknown error writing output to {json_file}: {e}")


def create_parser() -> argparse.ArgumentParser:
    """Create and return an argument parser for the pyproject.toml parser tool.

    Returns:
        Configured ArgumentParser instance with all supported command-line options.
    """
    parser = argparse.ArgumentParser(
        description="Parse pyproject.toml files in a directory and analyze Python projects"
    )
    parser.add_argument(
        "directory",
        type=str,
        nargs="?",
        default=str(cwd),
        help="Directory to scan for pyproject.toml files (default: current directory)",
    )
    parser.add_argument(
        "--debug", "-d", action="store_true", help="Enable debug logging output"
    )
    parser.add_argument(
        "--update",
        "-u",
        action="store_true",
        help="Force update by re-parsing projects instead of using cache",
    )
    return parser


def main() -> None:
    """Main entry point for the pyproject.toml parser tool.

    Parses command line arguments and creates a Solution instance by scanning
    the specified directory for pyproject.toml files. Uses cached data if
    available unless the update flag is set.
    """
    parser = create_parser()
    args = parser.parse_args()

    if args.debug:
        logger.setLevel(logging.DEBUG)

    Solution.from_directory(Path(args.directory), update=args.update)
