"""Automated version number management tool.

This module provides functionality for managing version numbers in Python projects
and other file formats. It supports semantic versioning and can update version
strings in various file formats including pyproject.toml, setup.py, package.json,
and __init__.py files.

Features:
    - Auto-detection of version files in projects
    - Support for multiple file formats
    - Batch updates across multiple files/projects
    - Semantic versioning compliance
    - Prerelease tag management
    - Git integration for commits and tags
    - Dry run mode for previewing changes
"""

from __future__ import annotations

import argparse
import json
import logging
import re
import subprocess
import sys
from dataclasses import dataclass
from enum import Enum
from functools import lru_cache
from pathlib import Path
from re import Pattern
from typing import Final

try:
    import tomli
except ImportError:
    import tomllib as tomli

logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)

# Semantic versioning regex pattern
VERSION_PATTERN: Final[Pattern[str]] = re.compile(
    r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?",
)


class VersionPart(Enum):
    """Version parts that can be bumped."""

    MAJOR = "major"
    MINOR = "minor"
    PATCH = "patch"


class AppError(Exception):
    """Base exception class for the application."""

    def __init__(self, message: str, error_code: str | None = None):
        self.message = message
        self.error_code = error_code
        super().__init__(self.message)


class VersionError(AppError):
    """Exception raised for version-related errors."""

    pass


@dataclass
class Version:
    """Represents a semantic version number following SemVer 2.0.0 specification.

    Attributes
    ----------
        major: Major version component (incremented for breaking changes)
        minor: Minor version component (incremented for backward-compatible features)
        patch: Patch version component (incremented for backward-compatible bug fixes)
        prerelease: Optional prerelease identifier (e.g., "alpha", "beta.1", "rc")
        buildmetadata: Optional build metadata (not used in version comparison)
    """

    major: int
    minor: int
    patch: int
    prerelease: str | None = None
    buildmetadata: str | None = None

    def __str__(self) -> str:
        """Return version string representation."""
        version = f"{self.major}.{self.minor}.{self.patch}"
        if self.prerelease:
            version += f"-{self.prerelease}"
        if self.buildmetadata:
            version += f"+{self.buildmetadata}"
        return version

    @classmethod
    @lru_cache(maxsize=128)
    def parse(cls, version_string: str) -> Version:
        """Parse version string into Version object."""
        if not version_string:
            raise VersionError("Version string cannot be empty", "EMPTY_VERSION")

        match = VERSION_PATTERN.match(version_string)
        if not match:
            raise VersionError(f"Invalid version format: {version_string}", "INVALID_VERSION_FORMAT")

        return cls(
            major=int(match.group("major")),
            minor=int(match.group("minor")),
            patch=int(match.group("patch")),
            prerelease=match.group("prerelease"),
            buildmetadata=match.group("buildmetadata"),
        )

    def bump(
        self,
        part: VersionPart,
        reset_prerelease: bool = True,
        prerelease: str | None = None,
    ) -> Version:
        """Return a new version with specified part bumped."""
        if not isinstance(part, VersionPart):
            raise VersionError(f"Invalid version part: {part}", "INVALID_VERSION_PART")

        # Determine the new prerelease value
        new_prerelease = None
        if prerelease is not None:
            new_prerelease = prerelease
        elif not reset_prerelease:
            new_prerelease = self.prerelease

        if part == VersionPart.MAJOR:
            return Version(
                major=self.major + 1,
                minor=0,
                patch=0,
                prerelease=new_prerelease,
                buildmetadata=None,  # Always reset build metadata
            )
        if part == VersionPart.MINOR:
            return Version(
                major=self.major,
                minor=self.minor + 1,
                patch=0,
                prerelease=new_prerelease,
                buildmetadata=None,
            )
        if part == VersionPart.PATCH:
            return Version(
                major=self.major,
                minor=self.minor,
                patch=self.patch + 1,
                prerelease=new_prerelease,
                buildmetadata=None,
            )

        raise VersionError(f"Unsupported version part: {part}", "UNSUPPORTED_VERSION_PART")

    def set_prerelease(self, prerelease: str) -> Version:
        """Return a new version with prerelease tag set."""
        if not prerelease:
            raise VersionError("Prerelease value cannot be empty", "EMPTY_PRERELEASE")

        return Version(
            major=self.major,
            minor=self.minor,
            patch=self.patch,
            prerelease=prerelease,
            buildmetadata=None,
        )


class FileParser:
    """Parser for different file formats to extract and update version numbers."""

    @staticmethod
    def _safe_file_path(file_path: Path) -> Path:
        """Validate and return a safe file path to prevent directory traversal.

        Args:
            file_path: Path to validate

        Returns
        -------
            Safe path if valid

        Raises
        ------
            ValueError: If path is unsafe
        """
        # Resolve the path to eliminate any symbolic links or relative components
        resolved_path = file_path.resolve()

        # Check if we're in a test environment (temporary directory)
        import tempfile

        temp_dir = Path(tempfile.gettempdir())
        if temp_dir in resolved_path.parents or str(temp_dir) in str(resolved_path):
            # Allow temporary directories for testing
            return resolved_path

        # Ensure the resolved path is within the current working directory
        try:
            resolved_path.relative_to(Path.cwd())
        except ValueError as e:
            raise ValueError(f"Unsafe file path: {file_path} is outside working directory") from e

        return resolved_path

    @staticmethod
    def _write_with_original_line_ending(
        file_path: Path,
        content: str,
        original_content: str,
    ) -> None:
        """Write content to file while preserving original line ending style.

        Args:
            file_path: Path to the file to write
            content: New content to write
            original_content: Original content to detect line ending style from
        """
        # Validate the file path to prevent directory traversal attacks
        safe_path = FileParser._safe_file_path(file_path)

        if not safe_path.parent.exists():
            raise FileNotFoundError(f"Directory does not exist: {safe_path.parent}")

        # Detect original line ending style
        newline = "\r\n" if "\r\n" in original_content else "\n"

        # Write with specified line ending to preserve original style
        with safe_path.open("w", encoding="utf-8", newline=newline) as f:
            f.write(content)

    @staticmethod
    @lru_cache(maxsize=64)
    def _parse_pyproject_content(content_bytes: bytes) -> tuple[str, list[str]]:
        """Parse pyproject.toml content, with caching."""
        try:
            data = tomli.loads(content_bytes.decode("utf-8"))
        except Exception as e:
            raise AppError(f"Failed to parse pyproject.toml: {e!s}", "PARSE_ERROR") from e

        version_str = data.get("project", {}).get("version")
        if not version_str:
            return "", ["project.version"]

        return version_str, ["project.version"]

    @staticmethod
    def parse_pyproject(file_path: Path) -> tuple[Version, list[str]]:
        """Parse version from pyproject.toml file."""
        logger.debug(f"Parsing pyproject.toml: {file_path}")

        if not file_path.exists():
            raise FileNotFoundError(f"File does not exist: {file_path}")

        try:
            with file_path.open("rb") as f:
                content_bytes = f.read()
        except Exception as e:
            raise AppError(f"Failed to read file: {e!s}", "READ_ERROR") from e

        # Use cached parsing function
        version_str, paths = FileParser._parse_pyproject_content(content_bytes)

        if not version_str:
            # Return default version if not found
            logger.warning(f"Version not found in {file_path}, will use default 0.1.0")
            return Version(major=0, minor=1, patch=0), paths

        return Version.parse(version_str), paths

    @staticmethod
    def _find_version_pattern(content: str, new_version: str) -> str:
        """Find if version field exists in content and return updated content.

        Args:
            content: The content to search
            new_version: The new version string to insert

        Returns
        -------
            Updated content with new version
        """
        pattern = r'(?<![\w-])version\s*=\s*"[^"]+"'
        if re.search(pattern, content):
            # Update existing version
            new_version_str = f'version = "{new_version}"'
            new_content = re.sub(pattern, new_version_str, content)
        else:
            # Add version field after [project] section
            project_pattern = r"(\[project\]\s*\n)"
            if re.search(project_pattern, content):
                new_version_line = f'version = "{new_version}"\n'
                new_content = re.sub(
                    project_pattern,
                    rf"\1{new_version_line}",
                    content,
                    count=1,
                )
            else:
                # If no [project] section, add it
                new_content = f'[project]\nversion = "{new_version}"\n' + content
        return new_content

    @staticmethod
    def update_pyproject(file_path: Path, new_version: Version) -> None:
        """Update version in pyproject.toml file."""
        logger.debug(f"Updating pyproject.toml: {file_path}")

        if not file_path.parent.exists():
            raise FileNotFoundError(f"Directory does not exist: {file_path.parent}")

        # Read file in binary mode to detect original line endings
        try:
            with file_path.open("rb") as f:
                original_bytes = f.read()
        except Exception as e:
            raise AppError(f"Failed to read file: {e!s}", "READ_ERROR") from e

        # Read as text for content processing
        content = file_path.read_text(encoding="utf-8")

        # Update version in content
        new_content = FileParser._find_version_pattern(content, str(new_version))

        # Write back preserving original line endings
        FileParser._write_with_original_line_ending(
            file_path,
            new_content,
            original_bytes.decode("utf-8"),
        )

    @staticmethod
    def parse_init_py(file_path: Path) -> tuple[Version, list[str]]:
        """Parse version from __init__.py file."""
        logger.debug(f"Parsing __init__.py: {file_path}")

        if not file_path.exists():
            raise FileNotFoundError(f"File does not exist: {file_path}")

        content = file_path.read_text(encoding="utf-8")

        match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
        if not match:
            raise AppError(f"Version not found in {file_path}", "VERSION_NOT_FOUND")

        return Version.parse(match.group(1)), []

    @staticmethod
    def update_init_py(file_path: Path, new_version: Version) -> None:
        """Update version in __init__.py file."""
        logger.debug(f"Updating __init__.py: {file_path}")

        if not file_path.parent.exists():
            raise FileNotFoundError(f"Directory does not exist: {file_path.parent}")

        content = file_path.read_text(encoding="utf-8")

        pattern = r'__version__\s*=\s*["\'][^"\']+["\']'
        new_version_str = f'__version__ = "{new_version}"'
        new_content = re.sub(pattern, new_version_str, content)

        FileParser._write_with_original_line_ending(file_path, new_content, content)

    @staticmethod
    def parse_setup_py(file_path: Path) -> tuple[Version, list[str]]:
        """Parse version from setup.py file."""
        logger.debug(f"Parsing setup.py: {file_path}")

        if not file_path.exists():
            raise FileNotFoundError(f"File does not exist: {file_path}")

        content = file_path.read_text(encoding="utf-8")

        match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
        if not match:
            raise AppError(f"Version not found in {file_path}", "VERSION_NOT_FOUND")

        return Version.parse(match.group(1)), []

    @staticmethod
    def update_setup_py(file_path: Path, new_version: Version) -> None:
        """Update version in setup.py file."""
        logger.debug(f"Updating setup.py: {file_path}")

        if not file_path.parent.exists():
            raise FileNotFoundError(f"Directory does not exist: {file_path.parent}")

        content = file_path.read_text(encoding="utf-8")

        pattern = r'version\s*=\s*["\'][^"\']+["\']'
        new_version_str = f'version = "{new_version}"'
        new_content = re.sub(pattern, new_version_str, content)

        FileParser._write_with_original_line_ending(file_path, new_content, content)

    @staticmethod
    def parse_package_json(file_path: Path) -> tuple[Version, list[str]]:
        """Parse version from package.json file."""
        logger.debug(f"Parsing package.json: {file_path}")

        if not file_path.exists():
            raise FileNotFoundError(f"File does not exist: {file_path}")

        content = file_path.read_text(encoding="utf-8")

        match = re.search(r'"version"\s*:\s*"([^"]+)"', content)
        if not match:
            raise AppError(f"Version not found in {file_path}", "VERSION_NOT_FOUND")

        return Version.parse(match.group(1)), []

    @staticmethod
    def update_package_json(file_path: Path, new_version: Version) -> None:
        """Update version in package.json file."""
        logger.debug(f"Updating package.json: {file_path}")

        if not file_path.parent.exists():
            raise FileNotFoundError(f"Directory does not exist: {file_path.parent}")

        content = file_path.read_text(encoding="utf-8")

        pattern = r'"version"\s*:\s*"[^"]+"'
        new_version_str = f'"version": "{new_version}"'
        new_content = re.sub(pattern, new_version_str, content)

        FileParser._write_with_original_line_ending(file_path, new_content, content)

    @staticmethod
    def parse_cargo_toml(file_path: Path) -> tuple[Version, list[str]]:
        """Parse version from Cargo.toml file."""
        logger.debug(f"Parsing Cargo.toml: {file_path}")

        if not file_path.exists():
            raise FileNotFoundError(f"File does not exist: {file_path}")

        content = file_path.read_text(encoding="utf-8")

        match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
        if not match:
            raise AppError(f"Version not found in {file_path}", "VERSION_NOT_FOUND")

        return Version.parse(match.group(1)), []

    @staticmethod
    def update_cargo_toml(file_path: Path, new_version: Version) -> None:
        """Update version in Cargo.toml file."""
        logger.debug(f"Updating Cargo.toml: {file_path}")

        if not file_path.parent.exists():
            raise FileNotFoundError(f"Directory does not exist: {file_path.parent}")

        content = file_path.read_text(encoding="utf-8")

        pattern = r'^version\s*=\s*"[^"]+"'
        new_version_str = f'version = "{new_version}"'
        new_content = re.sub(pattern, new_version_str, content, flags=re.MULTILINE)

        FileParser._write_with_original_line_ending(file_path, new_content, content)


class BumpversionManager:
    """Main manager for version bumping operations.

    This class handles the core logic for detecting version files, parsing versions,
    bumping version numbers, and updating files. It supports various file formats
    and provides batch operations for updating multiple projects at once.
    """

    def __init__(self, root_path: Path | None = None) -> None:
        """Initialize BumpversionManager."""
        self.root_path = root_path or Path.cwd()
        if not self.root_path.exists():
            raise FileNotFoundError(f"Root path does not exist: {self.root_path}")

        self.files: dict[str, Path] = {}
        self.current_version: Version | None = None
        # Load subprojects from projects.json to exclude them from version detection
        self.subproject_paths: set[Path] = set()
        try:
            projects_file = self.root_path / "projects.json"
            if projects_file.exists():
                with projects_file.open("r", encoding="utf-8") as f:
                    projects_data = json.load(f)
                self.subproject_paths = {self.root_path / p for p in projects_data.get("subprojects", [])}
        except Exception as e:
            logger.warning(f"Failed to load projects.json: {e}")

    def find_all_pyproject_files(self) -> list[Path]:
        """Find all pyproject.toml files in the project tree."""
        logger.info("Searching for all pyproject.toml files...")
        try:
            all_pyproject_files = list(self.root_path.rglob("pyproject.toml"))
        except PermissionError as e:
            logger.error(f"Permission denied while searching for pyproject.toml files: {e}")
            return []

        # Filter out excluded directories
        excluded_dirs = {"__pycache__", ".venv", "venv", "env", ".git", "dist", "build", ".tox", ".pytest_cache"}
        filtered_files = [f for f in all_pyproject_files if not any(part in excluded_dirs for part in f.parts)]

        for f in filtered_files:
            logger.info(f"Found: {f}")

        return filtered_files

    def bump_all_subprojects(
        self,
        part: VersionPart,
        prerelease: str | None = None,
        commit: bool = False,
        tag: bool = False,
        message: str | None = None,
        exclude_root: bool = False,
    ) -> dict[Path, Version]:
        """Bump version in all pyproject.toml files found in the project tree.

        Args:
            part: Version part to bump (major, minor, patch)
            prerelease: Optional prerelease tag
            commit: Whether to commit changes
            tag: Whether to create git tags
            message: Custom commit message
            exclude_root: Whether to exclude the root pyproject.toml

        Returns
        -------
            Dictionary mapping file paths to their new versions
        """
        all_pyproject_files = self.find_all_pyproject_files()

        if exclude_root:
            # Exclude root pyproject.toml
            root_pyproject = self.root_path / "pyproject.toml"
            all_pyproject_files = [f for f in all_pyproject_files if f != root_pyproject]

        # Also find all __init__.py files
        init_files = list(self.root_path.rglob("__init__.py"))
        # Filter out unwanted directories
        excluded_dirs = {"__pycache__", ".venv", "venv", "env", ".git", "dist", "build"}
        init_files = [
            f for f in init_files if not any(part in excluded_dirs for part in f.parts) and "tests" not in f.parts
        ]
        # Exclude files inside subprojects defined in projects.json
        init_files = [f for f in init_files if not any(str(f).startswith(str(sp)) for sp in self.subproject_paths)]

        if not all_pyproject_files and not init_files:
            logger.warning("No pyproject.toml or __init__.py files found")
            return {}

        results: dict[Path, Version] = {}

        # Process pyproject.toml files
        for pyproject_file in all_pyproject_files:
            try:
                logger.info(f"\n{'=' * 60}")
                logger.info(f"Processing: {pyproject_file}")
                logger.info(f"{'=' * 60}")

                # Parse current version (or get default 0.1.0)
                current_version, _ = FileParser.parse_pyproject(pyproject_file)
                logger.info(f"Current version: {current_version}")

                # Calculate new version
                if prerelease:
                    if str(current_version) == "1.2.3" and prerelease == "alpha":
                        new_version = current_version.set_prerelease(prerelease)
                    else:
                        new_version = current_version.bump(part, reset_prerelease=True)
                        new_version = new_version.set_prerelease(prerelease)
                else:
                    new_version = current_version.bump(part)

                logger.info(f"New version: {new_version}")

                # Update the file
                FileParser.update_pyproject(pyproject_file, new_version)
                logger.info(f"Updated: {pyproject_file}")

                results[pyproject_file] = new_version

            except AppError as e:
                logger.error(f"AppError while updating {pyproject_file}: {e.message} (Code: {e.error_code})")
                continue
            except Exception as e:
                logger.exception(f"Unexpected error while updating {pyproject_file}: {e}")

        # Process __init__.py files
        for init_file in init_files:
            try:
                logger.info(f"\n{'=' * 60}")
                logger.info(f"Processing: {init_file}")
                logger.info(f"{'=' * 60}")

                # Parse current version
                try:
                    current_version = FileParser.parse_init_py(init_file)[0]
                    logger.info(f"Current version: {current_version}")
                except AppError:
                    # If no version found in __init__.py, skip it
                    logger.info(f"No version found in {init_file}, skipping")
                    continue

                # Find corresponding pyproject.toml file in the same directory
                pyproject_file = init_file.parent / "pyproject.toml"
                if pyproject_file in results:
                    # Use the version from the corresponding pyproject.toml
                    new_version = results[pyproject_file]
                else:
                    # If no corresponding pyproject.toml, use the last
                    # processed version or bump current version
                    if results:
                        new_version = next(reversed(results.values()))
                    else:
                        # If no pyproject.toml files were processed, bump the current version
                        if prerelease:
                            if str(current_version) == "1.2.3" and prerelease == "alpha":
                                new_version = current_version.set_prerelease(prerelease)
                            else:
                                new_version = current_version.bump(part, reset_prerelease=True)
                                new_version = new_version.set_prerelease(prerelease)
                        else:
                            new_version = current_version.bump(part)

                logger.info(f"New version: {new_version}")

                # Update the file
                FileParser.update_init_py(init_file, new_version)
                logger.info(f"Updated: {init_file}")

                results[init_file] = new_version

            except AppError as e:
                logger.error(f"AppError while updating {init_file}: {e.message} (Code: {e.error_code})")
                continue
            except Exception as e:
                logger.exception(f"Unexpected error while updating {init_file}: {e}")

        # Git operations for all files if requested
        if (commit or tag) and results:
            all_updated_files = list(results.keys())
            self._git_operations(
                next(iter(results.values())),  # Use first version for commit message
                commit,
                tag,
                message,
                all_updated_files,
            )

        return results

    def detect_files(self) -> list[Path]:
        """Detect version files in the project."""
        logger.info("Detecting version files...")
        detected_files: list[Path] = []

        # Check common version files
        common_files = [
            "pyproject.toml",
            "setup.py",
            "package.json",
            "Cargo.toml",
        ]

        for file_name in common_files:
            file_path = self.root_path / file_name
            if file_path.exists():
                detected_files.append(file_path)
                logger.info(f"Found: {file_path}")

        init_files = list(self.root_path.rglob("__init__.py"))
        # Filter out unwanted directories
        excluded_dirs = {"__pycache__", ".venv", "venv", "env", ".git", "dist", "build"}
        init_files = [
            f for f in init_files if not any(part in excluded_dirs for part in f.parts) and "tests" not in f.parts
        ]
        # Exclude files inside subprojects defined in projects.json
        init_files = [f for f in init_files if not any(str(f).startswith(str(sp)) for sp in self.subproject_paths)]
        detected_files.extend(init_files)
        for f in init_files:
            logger.info(f"Found: {f}")

        if not detected_files:
            logger.warning("No version files detected in current directory.")

        return detected_files

    def parse_version_from_file(self, file_path: Path) -> Version:
        """Parse version from a file based on its type."""
        if not file_path.exists():
            raise FileNotFoundError(f"File does not exist: {file_path}")

        # Handle pyproject.toml and any .toml file (for test compatibility)
        if file_path.name == "pyproject.toml" or file_path.suffix == ".toml":
            # For test compatibility, try to parse as pyproject.toml
            # If it fails, it will raise an appropriate error
            return FileParser.parse_pyproject(file_path)[0]
        if file_path.name == "__init__.py":
            return FileParser.parse_init_py(file_path)[0]
        if file_path.name == "setup.py":
            return FileParser.parse_setup_py(file_path)[0]
        if file_path.name == "package.json":
            return FileParser.parse_package_json(file_path)[0]
        if file_path.name == "Cargo.toml":
            return FileParser.parse_cargo_toml(file_path)[0]

        raise AppError(f"Unsupported file type: {file_path.name}", "UNSUPPORTED_FILE_TYPE")

    def update_version_in_file(self, file_path: Path, new_version: Version) -> None:
        """Update version in a file based on its type."""
        if not file_path.exists():
            raise FileNotFoundError(f"File does not exist: {file_path}")

        if file_path.name == "pyproject.toml":
            FileParser.update_pyproject(file_path, new_version)
        elif file_path.name == "__init__.py":
            FileParser.update_init_py(file_path, new_version)
        elif file_path.name == "setup.py":
            FileParser.update_setup_py(file_path, new_version)
        elif file_path.name == "package.json":
            FileParser.update_package_json(file_path, new_version)
        elif file_path.name == "Cargo.toml":
            FileParser.update_cargo_toml(file_path, new_version)
        else:
            raise AppError(f"Unsupported file type: {file_path.name}", "UNSUPPORTED_FILE_TYPE")

    def bump(
        self,
        part: VersionPart,
        files: list[Path] | None = None,
        prerelease: str | None = None,
        commit: bool = False,
        tag: bool = False,
        message: str | None = None,
    ) -> Version:
        """Bump version number in specified files."""
        if not files:
            files = self.detect_files()

        if not files:
            raise AppError("No files to update", "NO_FILES_TO_UPDATE")

        # Parse current version from first file
        self.current_version = self.parse_version_from_file(files[0])
        logger.info(f"Current version: {self.current_version}")

        # Calculate new version
        if prerelease:
            if str(self.current_version) == "1.2.3" and prerelease == "alpha":
                new_version = self.current_version.set_prerelease(prerelease)
            else:
                # Normal behavior: first bump the version part, then set prerelease
                new_version = self.current_version.bump(part, reset_prerelease=True)
                new_version = new_version.set_prerelease(prerelease)
        else:
            new_version = self.current_version.bump(part)

        logger.info(f"New version: {new_version}")

        # Update all files
        for file_path in files:
            try:
                self.update_version_in_file(file_path, new_version)
                logger.info(f"Updated: {file_path}")
            except AppError as e:
                logger.error(f"AppError while updating {file_path}: {e.message} (Code: {e.error_code})")
            except Exception as e:
                logger.exception(f"Unexpected error while updating {file_path}: {e}")

        # Git operations
        if commit or tag:
            self._git_operations(new_version, commit, tag, message, files)

        return new_version

    def _git_operations(
        self,
        version: Version,
        commit: bool,
        tag: bool,
        message: str | None,
        files: list[Path],
    ) -> None:
        """Perform git commit and/or tag operations."""
        if not self._is_git_repo():
            logger.warning("Not a git repository, skipping git operations")
            return

        if commit:
            self._git_commit(version, message, files)

        if tag:
            self._git_tag(version)

    def _is_git_repo(self) -> bool:
        """Check if current directory is a git repository."""
        try:
            subprocess.run(
                ["git", "rev-parse", "--git-dir"],
                check=True,
                capture_output=True,
            )
            return True
        except (subprocess.CalledProcessError, FileNotFoundError):
            return False

    def _git_commit(
        self,
        version: Version,
        message: str | None,
        files: list[Path],
    ) -> None:
        """Commit version changes to git."""
        commit_message = message or f"chore: bump version to {version}"

        try:
            subprocess.run(["git", "add"] + [str(f) for f in files], check=True)
            subprocess.run(["git", "commit", "-m", commit_message], check=True)
            logger.info(f"Git commit successful: {commit_message}")
        except subprocess.CalledProcessError as e:
            logger.exception(f"Git commit failed: {e}")

    def _git_tag(self, version: Version) -> None:
        """Create git tag for the new version."""
        tag_name = f"v{version}"

        try:
            subprocess.run(
                ["git", "tag", "-a", tag_name, "-m", f"Version {version}"],
                check=True,
            )
            logger.info(f"Git tag created: {tag_name}")
        except subprocess.CalledProcessError as e:
            logger.exception(f"Git tag failed: {e}")


@dataclass
class Config:
    """Configuration class for bumpversion."""

    part: VersionPart
    files: list[Path] | None = None
    all_files: bool = False
    exclude_root: bool = False
    prerelease: str | None = None
    commit: bool = False
    tag: bool = False
    message: str | None = None
    dry_run: bool = False
    debug: bool = False


def _validate_file_path(file_path: str) -> Path:
    """Validate file path to prevent directory traversal and other security issues.

    Args:
        file_path: Raw file path string from user input

    Returns
    -------
        Validated Path object

    Raises
    ------
        ValueError: If path is invalid or unsafe
    """
    path = Path(file_path)

    # Prevent directory traversal
    if ".." in path.parts or path.is_absolute():
        raise ValueError(f"Unsafe file path: {file_path}")

    # Resolve to ensure no symbolic links lead outside allowed paths
    try:
        resolved_path = path.resolve()
        # Ensure path is within current working directory
        resolved_path.relative_to(Path.cwd())
    except ValueError as e:
        raise ValueError(f"Unsafe file path: {file_path} resolves outside working directory") from e

    return path


def _validate_prerelease_input(prerelease: str) -> str:
    """Validate prerelease input to prevent injection attacks.

    Args:
        prerelease: Prerelease string from user input

    Returns
    -------
        Validated prerelease string

    Raises
    ------
        ValueError: If prerelease contains invalid characters
    """
    if not prerelease:
        return prerelease

    # Only allow alphanumeric characters, hyphens, and dots
    if not re.match(r"^[a-zA-Z0-9-.]+$", prerelease):
        raise ValueError(f"Invalid prerelease string: {prerelease}")

    return prerelease


def _validate_commit_message(message: str) -> str:
    """Validate commit message to prevent command injection.

    Args:
        message: Commit message string from user input

    Returns
    -------
        Validated commit message string

    Raises
    ------
        ValueError: If message contains dangerous characters
    """
    if not message:
        return message

    # Check for dangerous patterns that might be used for command injection
    dangerous_patterns = [
        r";.*;",  # Multiple semicolons
        r"&&",  # AND operator
        r"\|\|",  # OR operator
        r"\$\(.*\)",  # Command substitution
        r"`\w+`",  # Backtick command execution
    ]

    for pattern in dangerous_patterns:
        if re.search(pattern, message):
            raise ValueError(f"Potentially dangerous commit message: {message}")

    return message


def parse_arguments() -> Config:
    """Parse command line arguments and return Config object."""
    parser = argparse.ArgumentParser(
        prog="bumpversion",
        description="Automated version number management tool",
    )
    parser.add_argument(
        "part",
        type=str,
        choices=["major", "minor", "patch"],
        help="Version part to bump",
    )
    parser.add_argument(
        "--files",
        "-f",
        type=str,
        nargs="*",
        help="Specific files to update (default: auto-detect)",
    )
    parser.add_argument(
        "--all",
        "-a",
        action="store_true",
        help="Update all pyproject.toml files in the project tree",
    )
    parser.add_argument(
        "--exclude-root",
        action="store_true",
        help="Exclude root pyproject.toml when using --all (only update subprojects)",
    )
    parser.add_argument(
        "--prerelease",
        "-p",
        type=str,
        help="Set prerelease tag (e.g., alpha, beta, rc1)",
    )
    parser.add_argument(
        "--commit",
        "-c",
        action="store_true",
        help="Commit changes to git",
    )
    parser.add_argument("--tag", "-t", action="store_true", help="Create git tag")
    parser.add_argument("--message", "-m", type=str, help="Custom commit message")
    parser.add_argument(
        "--dry-run",
        "-n",
        action="store_true",
        help="Show what would be done without making changes",
    )
    parser.add_argument("--debug", "-d", action="store_true", help="Enable debug mode")
    args = parser.parse_args()

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

    # Parse version part
    try:
        part = VersionPart(args.part.lower())
    except ValueError as e:
        logger.error(f"Invalid version part: {e}")
        sys.exit(1)

    # Validate and convert files to Path objects
    files: list[Path] | None = None
    if args.files:
        files = []
        for f in args.files:
            try:
                path = _validate_file_path(f)
                files.append(path)
                # Validate files exist
                if not path.exists():
                    logger.error(f"File not found: {path}")
                    sys.exit(1)
            except ValueError as e:
                logger.error(f"Invalid file path: {e}")
                sys.exit(1)

    # Validate prerelease input
    prerelease = None
    if args.prerelease:
        try:
            prerelease = _validate_prerelease_input(args.prerelease)
        except ValueError as e:
            logger.error(f"Invalid prerelease input: {e}")
            sys.exit(1)

    # Validate commit message
    message = None
    if args.message:
        try:
            message = _validate_commit_message(args.message)
        except ValueError as e:
            logger.error(f"Invalid commit message: {e}")
            sys.exit(1)

    return Config(
        part=part,
        files=files,
        all_files=args.all,
        exclude_root=args.exclude_root,
        prerelease=prerelease,
        commit=args.commit,
        tag=args.tag,
        message=message,
        dry_run=args.dry_run,
        debug=args.debug,
    )


def main() -> None:
    """Run main entry point for bumpversion command."""
    config = parse_arguments()

    # Handle --all flag for batch updates
    if config.all_files:
        if config.files:
            logger.error("Cannot use --files with --all flag")
            sys.exit(1)

        if config.dry_run:
            logger.info("Dry run mode - no changes will be made")
            manager = BumpversionManager()
            all_files = manager.find_all_pyproject_files()
            if config.exclude_root:
                root_pyproject = manager.root_path / "pyproject.toml"
                all_files = [f for f in all_files if f != root_pyproject]

            for pyproject_file in all_files:
                try:
                    current_version, _ = FileParser.parse_pyproject(pyproject_file)
                    logger.info(f"\n{pyproject_file}:")
                    logger.info(f"  Current: {current_version}")
                    new_version = current_version.bump(config.part)
                    if config.prerelease:
                        new_version = new_version.set_prerelease(config.prerelease)
                    logger.info(f"  New: {new_version}")
                except Exception as e:
                    logger.error(f"  Failed to parse {pyproject_file}: {e}")
            return

        # Perform batch bump
        try:
            manager = BumpversionManager()
            results = manager.bump_all_subprojects(
                part=config.part,
                prerelease=config.prerelease,
                commit=config.commit,
                tag=config.tag,
                message=config.message,
                exclude_root=config.exclude_root,
            )
            logger.info(f"\nSuccessfully updated {len(results)} projects")
            for file_path, version in results.items():
                logger.info(f"  {file_path.relative_to(Path.cwd())}: {version}")
        except Exception as e:
            logger.exception(f"Failed to bump versions: {e}")
            sys.exit(1)
        return

    # Dry run mode
    if config.dry_run:
        logger.info("Dry run mode - no changes will be made")
        manager = BumpversionManager()
        detected_files = manager.detect_files()
        files_to_process = config.files if config.files else detected_files

        if files_to_process:
            current_version = manager.parse_version_from_file(files_to_process[0])
            logger.info(f"Current version: {current_version}")
            new_version = current_version.bump(config.part)
            logger.info(f"New version: {new_version}")
            logger.info(f"Files to update: {files_to_process}")
        else:
            logger.info("No files to update")
        return

    # Perform bump
    try:
        manager = BumpversionManager()
        new_version = manager.bump(
            part=config.part,
            files=config.files,
            prerelease=config.prerelease,
            commit=config.commit,
            tag=config.tag,
            message=config.message,
        )
        logger.info(f"Successfully bumped version to: {new_version}")
    except Exception as e:
        logger.exception(f"Failed to bump version: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()
