"""Configuration management for pyproject.toml.

This module provides the PyprojectConfigFile class for managing the
project's pyproject.toml file. It handles project metadata, dependencies,
tool configurations (ruff, mypy, pytest, bandit), and build settings.

The configuration enforces pyrig's opinionated defaults:
    - All ruff rules enabled (with minimal exceptions)
    - Strict mypy type checking
    - Bandit security scanning
    - uv as the build backend
"""

import re
from functools import cache
from pathlib import Path
from subprocess import CompletedProcess  # nosec: B404
from typing import Any, Literal

import requests
from packaging.version import Version

from pyrig.dev.configs.base.base import TomlConfigFile
from pyrig.src.os.os import run_subprocess
from pyrig.src.project.versions import VersionConstraint, adjust_version_to_level
from pyrig.src.testing.convention import (
    COVERAGE_THRESHOLD,
    TEST_MODULE_PREFIX,
    TESTS_PACKAGE_NAME,
)


class PyprojectConfigFile(TomlConfigFile):
    """Configuration file manager for pyproject.toml.

    Manages the central project configuration including:
        - Project metadata (name, description, dependencies)
        - Build system configuration (uv)
        - Tool configurations (ruff, mypy, pytest, bandit)
        - CLI entry points

    The class provides utilities for querying project information
    and managing dependencies.
    """

    @classmethod
    def dump(cls, config: dict[str, Any] | list[Any]) -> None:
        """Write configuration to pyproject.toml.

        Normalizes dependencies before writing.

        Args:
            config: The configuration dict to write.

        Raises:
            TypeError: If config is not a dict.
        """
        if not isinstance(config, dict):
            msg = f"Cannot dump {config} to pyproject.toml file."
            raise TypeError(msg)
        # remove the versions from the dependencies
        cls.remove_wrong_dependencies(config)
        super().dump(config)

    @classmethod
    def get_parent_path(cls) -> Path:
        """Get the project root directory.

        Returns:
            Path to the project root.
        """
        return Path()

    @classmethod
    def get_project_name_from_cwd(cls) -> str:
        """Derive the project name from the current directory.

        The project name is assumed to match the directory name.

        Returns:
            The current directory name.
        """
        cwd = Path.cwd()
        return cwd.name

    @classmethod
    def get_pkg_name_from_cwd(cls) -> str:
        """Derive the package name from the current directory.

        Returns:
            The package name (directory name with hyphens as underscores).
        """
        return cls.get_pkg_name_from_project_name(cls.get_project_name_from_cwd())

    @classmethod
    def get_project_description(cls) -> str:
        """Get the project description from pyproject.toml.

        Returns:
            The project description or empty string.
        """
        return str(cls.load().get("project", {}).get("description", ""))

    @classmethod
    def get_configs(cls) -> dict[str, Any]:
        """Get the expected pyproject.toml configuration.

        Returns:
            Complete configuration dict with project metadata,
            dependencies, build system, and tool configurations.
        """
        from pyrig.dev.cli import (  # noqa: PLC0415
            cli,
        )

        return {
            "project": {
                "name": cls.get_project_name_from_cwd(),
                "readme": "README.md",
                "scripts": {
                    cls.get_project_name(): f"{cli.__name__}:{cli.main.__name__}"
                },
                "dependencies": cls.make_dependency_versions(cls.get_dependencies()),
            },
            "dependency-groups": {
                "dev": cls.make_dependency_versions(
                    cls.get_dev_dependencies(),
                    additional=cls.get_standard_dev_dependencies(),
                )
            },
            "build-system": {
                "requires": ["uv_build"],
                "build-backend": "uv_build",
            },
            "tool": {
                "uv": {
                    "build-backend": {
                        "module-name": cls.get_pkg_name_from_cwd(),
                        "module-root": "",
                    }
                },
                "ruff": {
                    "exclude": [".*", "**/migrations/*.py"],
                    "lint": {
                        "select": ["ALL"],
                        "ignore": ["D203", "D213", "COM812", "ANN401"],
                        "fixable": ["ALL"],
                        "per-file-ignores": {
                            f"{TESTS_PACKAGE_NAME}/**/*.py": ["S101"],
                        },
                        "pydocstyle": {"convention": "google"},
                    },
                },
                "mypy": {
                    "strict": True,
                    "warn_unreachable": True,
                    "show_error_codes": True,
                    "files": ".",
                },
                "pytest": {
                    "ini_options": {
                        "testpaths": [TESTS_PACKAGE_NAME],
                        "addopts": f"--cov={cls.get_package_name()} --cov-report=term-missing --cov-fail-under={COVERAGE_THRESHOLD}",  # noqa: E501
                    }
                },
                "bandit": {
                    "exclude_dirs": [
                        ".*",
                    ],
                    "assert_used": {
                        "skips": [f"*{TEST_MODULE_PREFIX}*.py"],
                    },
                },
            },
        }

    @classmethod
    def should_remove_version_from_dep(cls) -> bool:
        """Determine whether to strip version specifiers from dependencies.

        Override in subclasses to preserve version specifiers.

        Returns:
            True to remove versions, False to preserve them.
        """
        return True

    @classmethod
    def remove_wrong_dependencies(cls, config: dict[str, Any]) -> None:
        """Normalize dependencies by removing version specifiers.

        Args:
            config: The configuration dict to modify in place.
        """
        # removes the versions from the dependencies
        config["project"]["dependencies"] = cls.make_dependency_versions(
            config["project"]["dependencies"]
        )
        config["dependency-groups"]["dev"] = cls.make_dependency_versions(
            config["dependency-groups"]["dev"]
        )

    @classmethod
    def make_dependency_versions(
        cls,
        dependencies: list[str],
        additional: list[str] | None = None,
    ) -> list[str]:
        """Normalize and merge dependency lists.

        Args:
            dependencies: Primary dependencies to process.
            additional: Additional dependencies to merge.

        Returns:
            Sorted, deduplicated list of normalized dependencies.
        """
        if additional is None:
            additional = []
        dependencies.extend(additional)
        deps: list[str] = []
        for dep in dependencies:
            at_file_dep = "file://"
            if at_file_dep in dep:
                new_dep = dep
            elif cls.should_remove_version_from_dep():
                # remove version if it exists by split re on first non alnum or _ -
                new_dep = cls.remove_version_from_dep(dep)
            else:
                new_dep = dep
            deps.append(new_dep)
        return sorted(set(deps))

    @classmethod
    def remove_version_from_dep(cls, dep: str) -> str:
        """Strip version specifier from a dependency string.

        Args:
            dep: Dependency string like "requests>=2.0".

        Returns:
            Package name without version (e.g., "requests").
        """
        return re.split(r"[^a-zA-Z0-9_.-]", dep)[0]

    @classmethod
    def get_package_name(cls) -> str:
        """Get the Python package name (with underscores).

        Returns:
            The package name derived from the project name.
        """
        project_name = cls.get_project_name()
        return cls.get_pkg_name_from_project_name(project_name)

    @classmethod
    def get_pkg_name_from_project_name(cls, project_name: str) -> str:
        """Convert a project name to a package name.

        Args:
            project_name: Project name with hyphens.

        Returns:
            Package name with underscores.
        """
        return project_name.replace("-", "_")

    @classmethod
    def get_project_name_from_pkg_name(cls, pkg_name: str) -> str:
        """Convert a package name to a project name.

        Args:
            pkg_name: Package name with underscores.

        Returns:
            Project name with hyphens.
        """
        return pkg_name.replace("_", "-")

    @classmethod
    def get_project_name(cls) -> str:
        """Get the project name from pyproject.toml.

        Returns:
            The project name or empty string.
        """
        return str(cls.load().get("project", {}).get("name", ""))

    @classmethod
    def get_all_dependencies(cls) -> list[str]:
        """Get all dependencies (runtime and dev).

        Returns:
            Combined list of all dependencies.
        """
        all_deps = cls.get_dependencies()
        all_deps.extend(cls.get_dev_dependencies())
        return all_deps

    @classmethod
    def get_standard_dev_dependencies(cls) -> list[str]:
        """Get pyrig's standard development dependencies.

        Returns:
            Sorted list of standard dev dependencies.
        """
        standard_dev_dependencies: list[str] = [
            "bandit",
            "mypy",
            "pre-commit",
            "pytest",
            "pytest-mock",
            "pytest-cov",
            "ruff",
            "types-defusedxml",
            "types-pyinstaller",
            "types-pyyaml",
            "types-setuptools",
            "types-tqdm",
            "pyinstaller",
        ]
        # sort the dependencies
        return sorted(standard_dev_dependencies)

    @classmethod
    def get_dev_dependencies(cls) -> list[str]:
        """Get development dependencies from pyproject.toml.

        Returns:
            List of dev dependencies.
        """
        dev_deps: list[str] = cls.load().get("dependency-groups", {}).get("dev", [])
        return dev_deps

    @classmethod
    def get_dependencies(cls) -> list[str]:
        """Get runtime dependencies from pyproject.toml.

        Returns:
            List of runtime dependencies.
        """
        deps: list[str] = cls.load().get("project", {}).get("dependencies", [])
        return deps

    @classmethod
    @cache
    def fetch_latest_python_version(cls) -> Version:
        """Fetch the latest stable Python version from endoflife.date.

        Returns:
            The latest stable Python version.

        Raises:
            requests.HTTPError: If the API request fails.
        """
        url = "https://endoflife.date/api/python.json"
        resp = requests.get(url, timeout=10)
        resp.raise_for_status()
        data = resp.json()
        # first element has metadata for latest stable
        latest_version = data[0]["latest"]
        return Version(latest_version)

    @classmethod
    def get_latest_possible_python_version(
        cls, level: Literal["major", "minor", "micro"] = "micro"
    ) -> Version:
        """Get the latest Python version allowed by requires-python.

        Args:
            level: Version precision (major, minor, or micro).

        Returns:
            The latest allowed Python version.
        """
        constraint = cls.load()["project"]["requires-python"]
        version_constraint = VersionConstraint(constraint)
        version = version_constraint.get_upper_inclusive()
        if version is None:
            version = cls.fetch_latest_python_version()

        return adjust_version_to_level(version, level)

    @classmethod
    def get_first_supported_python_version(cls) -> Version:
        """Get the minimum supported Python version.

        Returns:
            The minimum Python version from requires-python.

        Raises:
            ValueError: If no lower bound is specified.
        """
        constraint = cls.load()["project"]["requires-python"]
        version_constraint = VersionConstraint(constraint)
        lower = version_constraint.get_lower_inclusive()
        if lower is None:
            msg = "Need a lower bound for python version"
            raise ValueError(msg)
        return lower

    @classmethod
    def get_supported_python_versions(cls) -> list[Version]:
        """Get all supported Python minor versions.

        Returns:
            List of supported Python versions (e.g., [3.10, 3.11, 3.12]).
        """
        constraint = cls.load()["project"]["requires-python"]
        version_constraint = VersionConstraint(constraint)
        return version_constraint.get_version_range(
            level="minor", upper_default=cls.fetch_latest_python_version()
        )

    @classmethod
    def update_dependencies(cls, *, check: bool = True) -> CompletedProcess[bytes]:
        """Update dependencies to their latest versions.

        Args:
            check: Whether to raise on non-zero exit code.

        Returns:
            The completed process result.
        """
        from pyrig.src.project.mgt import PROJECT_MGT  # noqa: PLC0415

        upgrade_deps = run_subprocess([PROJECT_MGT, "lock", "--upgrade"], check=check)
        _ = cls.install_dependencies(check=check)
        return upgrade_deps

    @classmethod
    def install_dependencies(cls, *, check: bool = True) -> CompletedProcess[bytes]:
        """Install project dependencies using uv sync.

        Args:
            check: Whether to raise on non-zero exit code.

        Returns:
            The completed process result.
        """
        from pyrig.src.project.mgt import PROJECT_MGT  # noqa: PLC0415

        return run_subprocess([PROJECT_MGT, "sync"], check=check)
