"""PyArchive - Multi-format project archiver with configurable compression.

This module provides a comprehensive archiving solution that supports multiple
compression formats (ZIP, TAR, 7z, NSIS) with configurable compression levels
and filtering capabilities.

The module follows established design patterns:
- Dataclass pattern for configuration management with persistence
- Frozen dataclass pattern for immutable archiver instances
- Strategy pattern for different archive format implementations
- Factory pattern for archive function selection
- Builder pattern for NSIS script generation
- Singleton pattern for logging configuration
- Enum pattern for type-safe format definitions
"""

from __future__ import annotations

import argparse
import atexit
import enum
import json
import logging
import shutil
import subprocess
import tarfile
import tempfile
import zipfile
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from typing import Any, Final

from sfi.pyprojectparse.models.solution import Solution
from sfi.pyprojectparse.models.project import Project

__version__ = "1.0.0"
__build__ = "20260202"
__author__ = "pysfi Development Team"

# Configuration constants
CONFIG_FILE: Final[Path] = Path.home() / ".pysfi" / "pyarchive.json"
DEFAULT_CACHE_DIR: Final[Path] = Path.home() / ".pysfi" / ".cache" / "pyarchive"

# Constants for archive operations
DEFAULT_COMPRESSION_LEVEL: Final[int] = 6
MAX_COMPRESSION_LEVEL: Final[int] = 9
MIN_COMPRESSION_LEVEL: Final[int] = 0
DEFAULT_OUTPUT_DIR: Final[str] = "build"
DEFAULT_DIST_DIR: Final[str] = "dist"
DEFAULT_MAX_WORKERS: Final[int] = 4

# File extensions for different archive formats
ARCHIVE_EXTENSIONS: Final[dict[str, str]] = {
    "zip": "zip",
    "tar": "tar",
    "gztar": "tar.gz",
    "bztar": "tar.bz2",
    "xztar": "tar.xz",
    "7z": "7z",
    "nsis": "setup.exe",
}

# NSIS script constants
NSIS_INCLUDE_FILE: Final[str] = "MUI2.nsh"
NSIS_ABORT_WARNING: Final[str] = "MUI_ABORTWARNING"
NSIS_LANGUAGE: Final[str] = "English"
NSIS_REGISTRY_KEY: Final[str] = "Software"
NSIS_PROGRAM_FILES: Final[str] = "$PROGRAMFILES"
NSIS_START_MENU: Final[str] = "$SMPROGRAMS"
NSIS_INSTALL_DIR: Final[str] = "$INSTDIR"

# NSIS page macros
NSIS_PAGES: Final[list[str]] = [
    "MUI_PAGE_WELCOME",
    "MUI_PAGE_COMPONENTS",
    "MUI_PAGE_DIRECTORY",
    "MUI_PAGE_INSTFILES",
    "MUI_PAGE_FINISH",
]

NSIS_UNPAGES: Final[list[str]] = [
    "MUI_UNPAGE_WELCOME",
    "MUI_UNPAGE_CONFIRM",
    "MUI_UNPAGE_INSTFILES",
    "MUI_UNPAGE_FINISH",
]

# NSIS section names
NSIS_MAIN_SECTION: Final[str] = "required"
NSIS_SHORTCUT_SECTION: Final[str] = "Start Menu Shortcuts"
NSIS_UNINSTALL_SECTION: Final[str] = "Uninstall"

# File operation constants
FILE_PERMISSION_READ: Final[int] = 0o444
FILE_PERMISSION_WRITE: Final[int] = 0o666
FILE_PERMISSION_EXECUTE: Final[int] = 0o777

# System command constants
CMD_WHERE: Final[str] = "where"
CMD_WHICH: Final[str] = "which"
CMD_7Z: Final[str] = "7z"
CMD_MAKENSIS: Final[str] = "makensis"

# Temporary directory constants
TEMP_DIR_PREFIX: Final[str] = "pyarchive_"

# Logging format constants
LOG_FORMAT: Final[str] = "%(levelname)s: %(message)s"
LOG_LEVEL_INFO: Final[str] = "INFO"
LOG_LEVEL_DEBUG: Final[str] = "DEBUG"
LOG_LEVEL_ERROR: Final[str] = "ERROR"

logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
logger = logging.getLogger(__name__)


class ArchiveError(Exception):
    """Base exception for archive operations."""

    pass


class ArchiveFormatError(ArchiveError):
    """Exception raised for unsupported archive formats."""

    pass


class ArchiveCommandError(ArchiveError):
    """Exception raised when external command is not available."""

    pass


class ArchiveCreationError(ArchiveError):
    """Exception raised when archive creation fails."""

    pass


class ArchiveFormat(enum.Enum):
    """Enumeration of supported archive formats."""

    ZIP = "zip"
    TAR = "tar"
    GZTAR = "gztar"
    BZTAR = "bztar"
    XZTAR = "xztar"
    SEVEN_ZIP = "7z"
    NSIS = "nsis"

    @classmethod
    def all_formats(cls) -> frozenset[str]:
        """Get all supported format strings."""
        return frozenset(fmt.value for fmt in cls)

    @property
    def description(self) -> str:
        """Get human-readable description of the format."""
        descriptions = {
            self.ZIP: "ZIP archive (DEFLATE compression)",
            self.TAR: "TAR archive (no compression)",
            self.GZTAR: "TAR archive with gzip compression",
            self.BZTAR: "TAR archive with bzip2 compression",
            self.XZTAR: "TAR archive with xz compression",
            self.SEVEN_ZIP: "7-Zip archive (high compression)",
            self.NSIS: "NSIS Windows installer",
        }
        return descriptions.get(self, f"{self.value} format")

    @property
    def file_extension(self) -> str:
        """Get the file extension for this format."""
        return ARCHIVE_EXTENSIONS.get(self.value, self.value)


ARCHIVE_FORMATS: frozenset[str] = ArchiveFormat.all_formats()

DEFAULT_IGNORE_PATTERNS: frozenset[str] = frozenset([
    "__pycache__",
    "*.pyc",
    "*.pyo",
    ".git",
    ".gitignore",
    ".pytest_cache",
    ".coverage",
    "*.egg-info",
    "build",
    "*.log",
    ".DS_Store",
    "Thumbs.db",
    ".ruff_cache",
    ".benchmarks",
])


@dataclass
class PyArchiveConfig:
    """Configuration for PyArchive with persistent settings."""

    compression_level: int = DEFAULT_COMPRESSION_LEVEL
    verbose: bool = False
    preserve_permissions: bool = True
    max_workers: int = DEFAULT_MAX_WORKERS
    cache_dir: Path | None = None
    output_dir: str = DEFAULT_OUTPUT_DIR
    dist_dir: str = DEFAULT_DIST_DIR

    def __post_init__(self) -> None:
        """Initialize configuration and load from file if exists."""
        if self.cache_dir is None:
            self.cache_dir = DEFAULT_CACHE_DIR
            object.__setattr__(self, "cache_dir", DEFAULT_CACHE_DIR)

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

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

        # Validate compression level
        if not MIN_COMPRESSION_LEVEL <= self.compression_level <= MAX_COMPRESSION_LEVEL:
            raise ValueError(
                f"Compression level must be between {MIN_COMPRESSION_LEVEL} and {MAX_COMPRESSION_LEVEL}"
            )

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


@dataclass
class ArchiveOptions:
    """Options for archive creation (legacy compatibility)."""

    compression_level: int = 6  # 0-9 for most formats
    verbose: bool = False
    preserve_permissions: bool = True

    def __post_init__(self) -> None:
        """Validate compression level."""
        if not MIN_COMPRESSION_LEVEL <= self.compression_level <= MAX_COMPRESSION_LEVEL:
            raise ValueError(
                f"Compression level must be between {MIN_COMPRESSION_LEVEL} and {MAX_COMPRESSION_LEVEL}"
            )


def should_ignore(file_path: Path, ignore_patterns: set[str]) -> bool:
    """Check if a file should be ignored based on patterns."""
    from fnmatch import fnmatch

    # Convert to string for pattern matching
    file_str = str(file_path)

    for pattern in ignore_patterns:
        if pattern.startswith("*."):
            # Check just the filename
            if fnmatch(file_path.name, pattern):
                return True
        else:
            # Check full path
            if pattern in file_str or fnmatch(file_str, pattern):
                return True

    return False


def load_projects(config_file: Path) -> dict[str, Any]:
    """Load projects configuration from JSON file."""
    if not config_file.exists():
        logger.error(f"Configuration file not found: {config_file}")
        return {}

    try:
        with open(config_file, encoding="utf-8") as f:
            return json.load(f)
    except json.JSONDecodeError as e:
        logger.error(f"Invalid JSON in configuration file {config_file}: {e}")
        return {}


@dataclass(frozen=True)
class PyArchiver:
    """Main archiver class for creating project archives."""

    root_dir: Path
    config: PyArchiveConfig = field(default_factory=PyArchiveConfig)

    def __post_init__(self) -> None:
        """Initialize archiver and register config auto-save."""
        atexit.register(self.config.save)

    @cached_property
    def solution(self) -> Solution:
        """Get the solution from the target directory."""
        return Solution.from_directory(self.root_dir)

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

    @cached_property
    def default_ignore_patterns(self) -> set[str]:
        """Get default ignore patterns."""
        return set(DEFAULT_IGNORE_PATTERNS)

    def should_ignore(
        self, file_path: Path, ignore_patterns: set[str] | None = None
    ) -> bool:
        """Check if a file should be ignored based on patterns."""
        from fnmatch import fnmatch

        patterns = ignore_patterns or self.default_ignore_patterns

        # Convert to string for pattern matching
        file_str = str(file_path)

        for pattern in patterns:
            if pattern.startswith("*."):
                # Check just the filename
                if fnmatch(file_path.name, pattern):
                    return True
            else:
                # Check full path
                if pattern in file_str or fnmatch(file_str, pattern):
                    return True

        return False

    def check_command_available(self, command: str) -> bool:
        """Check if a command is available in the system PATH."""
        try:
            cmd = (
                [CMD_WHERE, command]
                if shutil.which(CMD_WHERE)
                else [CMD_WHICH, command]
            )
            subprocess.run(
                cmd,
                capture_output=True,
                check=True,
                shell=True,
            )
            return True
        except (subprocess.CalledProcessError, FileNotFoundError):
            return False

    def validate_project(self, project: Project) -> bool:
        """Validate project configuration before archiving.

        Args:
            project: Project to validate

        Returns:
            True if project is valid, False otherwise

        Raises:
            ValueError: If project configuration is invalid
        """
        if not project.name:
            raise ValueError("Project name cannot be empty")

        if not project.version:
            raise ValueError("Project version cannot be empty")

        # Validate version format (basic check)
        version_parts = project.version.split(".")
        if len(version_parts) < 2:
            logger.warning(
                f"Project {project.name} has unusual version format: {project.version}"
            )

        return True

    def get_project_directory(self, project_name: str) -> Path | None:
        """Locate the project directory."""
        project_path = self.root_dir / project_name
        if project_path.exists() and project_path.is_dir():
            return project_path
        return None

    def _prepare_archive_paths(
        self, project: Project, file_extension: str
    ) -> tuple[Path, Path]:
        """Prepare source and destination paths for archiving."""
        dist_dir = self.root_dir / self.config.dist_dir
        output_dir = self.root_dir / self.config.output_dir
        output_dir.mkdir(parents=True, exist_ok=True)
        output_file = output_dir / f"{project.name}-{project.version}.{file_extension}"
        return dist_dir, output_file

    def _get_archive_function_and_extension(
        self, format_str: str
    ) -> tuple[callable, str] | tuple[None, None]:
        """Get the appropriate archive function and file extension for the given format."""
        format_functions = {
            "zip": (self._archive_zip, ArchiveFormat.ZIP.file_extension),
            "tar": (self._archive_tar, ArchiveFormat.TAR.file_extension),
            "gztar": (self._archive_gztar, ArchiveFormat.GZTAR.file_extension),
            "bztar": (self._archive_bztar, ArchiveFormat.BZTAR.file_extension),
            "xztar": (self._archive_xztar, ArchiveFormat.XZTAR.file_extension),
            "7z": (self._archive_7z, ArchiveFormat.SEVEN_ZIP.file_extension),
            "nsis": (self._archive_nsis, ArchiveFormat.NSIS.file_extension),
        }
        return format_functions.get(format_str, (None, None))

    def archive_project(
        self,
        project: Project,
        format: str,
        ignore_patterns: set[str] | None = None,
    ) -> bool:
        """Archive a single project.

        Args:
            project: Project to archive
            format: Archive format (zip, tar, gztar, etc.)
            ignore_patterns: Patterns to ignore during archiving

        Returns:
            True if archiving succeeded, False otherwise
        """
        logger.info(f"Processing project: {project.name}")

        # Validate project configuration
        try:
            self.validate_project(project)
        except ValueError as e:
            logger.error(f"Project validation failed: {e}")
            return False

        # Validate and get archive function
        archive_func, file_extension = self._get_archive_function_and_extension(format)
        if archive_func is None:
            logger.error(f"Unsupported format: {format}")
            return False

        # Prepare paths
        dist_dir, output_file = self._prepare_archive_paths(project, file_extension)

        if not dist_dir.exists():
            logger.warning(
                f"Project dist directory not found: {project.name}, please build project first"
            )
            return False

        patterns = ignore_patterns or self.default_ignore_patterns

        # Handle NSIS specially (requires project parameter)
        if format == "nsis":
            return self._archive_nsis(
                project=project,
                output_file=output_file,
                ignore_patterns=patterns,
            )

        # Call appropriate archive function
        return archive_func(
            dist_dir=dist_dir,
            output_file=output_file,
            ignore_patterns=patterns,
        )

    def archive_projects(
        self,
        format: str,
        projects_to_archive: list[str] | None = None,
        ignore_patterns: set[str] | None = None,
    ) -> tuple[int, int]:
        """Archive multiple projects and return (success_count, total_count).

        Args:
            format: Archive format (zip, tar, gztar, bztar, xztar, 7z, nsis)
            projects_to_archive: List of project names to archive (None = all)
            ignore_patterns: Additional patterns to ignore during archiving

        Returns:
            Tuple of (success_count, total_count)

        Raises:
            ArchiveFormatError: If the specified format is not supported
        """
        # Validate inputs
        if format not in ARCHIVE_FORMATS:
            logger.error(
                f"Unsupported format: {format}. Supported formats: {', '.join(ARCHIVE_FORMATS)}"
            )
            return 0, 0

        logger.debug(f"Archiving projects in {self.root_dir} to `{format}` format")
        logger.debug(f"Compression level: {self.config.compression_level}")
        if self.config.verbose:
            logger.debug("Verbose mode enabled")

        # Determine projects to archive
        projects_to_archive = projects_to_archive or list(self.projects.keys())
        if not projects_to_archive:
            logger.error("No projects to archive")
            return 0, 0

        logger.debug(f"Archiving projects: {', '.join(projects_to_archive)}")

        # Process projects
        success_count = 0
        total_count = 0

        for project_name in projects_to_archive:
            if project_name not in self.projects:
                logger.warning(f"Project not found: {project_name}")
                continue

            project = self.projects[project_name]
            total_count += 1

            if self.archive_project(
                project=project,
                format=format,
                ignore_patterns=ignore_patterns,
            ):
                success_count += 1

        # Report results
        if success_count:
            logger.info(
                f"Archiving complete: {success_count}/{total_count} projects successfully archived"
            )
        else:
            logger.error("Archiving failed")

        return success_count, total_count

    def get_archive_info(self, project: Project, format: str) -> dict[str, Any]:
        """Get information about an archive without creating it.

        Args:
            project: Project to get archive info for
            format: Archive format

        Returns:
            Dictionary containing archive information (name, extension, estimated_size, etc.)
        """
        _, file_extension = self._get_archive_function_and_extension(format)
        if file_extension is None:
            return {}

        dist_dir, output_file = self._prepare_archive_paths(project, file_extension)

        info = {
            "project_name": project.name,
            "project_version": project.version,
            "format": format,
            "output_file": str(output_file),
            "output_dir": str(output_file.parent),
            "dist_dir": str(dist_dir),
            "dist_exists": dist_dir.exists(),
        }

        # Calculate source size if dist exists
        if dist_dir.exists():
            total_size = sum(
                f.stat().st_size for f in dist_dir.rglob("*") if f.is_file()
            )
            info["source_size_bytes"] = total_size
            info["source_size_mb"] = round(total_size / (1024 * 1024), 2)

        return info

    def _archive_tar(
        self,
        dist_dir: Path,
        output_file: Path,
        ignore_patterns: set[str],
    ) -> bool:
        """Create TAR archive (no compression)."""
        try:
            logger.info(f"Creating TAR archive: {output_file}")
            output_file.parent.mkdir(parents=True, exist_ok=True)

            with tarfile.open(output_file, "w", format=tarfile.PAX_FORMAT) as tar:
                for file_path in dist_dir.rglob("*"):
                    if file_path.is_file() and not self.should_ignore(
                        file_path, ignore_patterns
                    ):
                        arcname = file_path.relative_to(dist_dir)
                        tarinfo = tar.gettarinfo(str(file_path), str(arcname))

                        if self.config.preserve_permissions:
                            # Preserve file permissions
                            tarinfo.mode = file_path.stat().st_mode

                        with open(file_path, "rb") as f:
                            tar.addfile(tarinfo, f)

                        if self.config.verbose:
                            logger.debug(f"Added: {arcname}")

            logger.info(f"TAR archive created successfully: {output_file}")
            return True
        except Exception as e:
            logger.error(f"Failed to create TAR archive: {e}")
            return False

    def _archive_gztar(
        self,
        dist_dir: Path,
        output_file: Path,
        ignore_patterns: set[str],
    ) -> bool:
        """Create gzip-compressed TAR archive."""
        try:
            logger.info(f"Creating gzip-compressed TAR archive: {output_file}")
            output_file.parent.mkdir(parents=True, exist_ok=True)

            # Map compression level to gzip compresslevel (0-9)
            compresslevel = max(0, min(9, self.config.compression_level))

            with tarfile.open(output_file, "w:gz", compresslevel=compresslevel) as tar:
                for file_path in dist_dir.rglob("*"):
                    if file_path.is_file() and not self.should_ignore(
                        file_path, ignore_patterns
                    ):
                        arcname = file_path.relative_to(dist_dir)
                        tar.add(str(file_path), str(arcname))
                        if self.config.verbose:
                            logger.debug(f"Added: {arcname}")

            logger.info(
                f"Gzip-compressed TAR archive created successfully: {output_file}"
            )
            return True
        except Exception as e:
            logger.error(f"Failed to create gzip-compressed TAR archive: {e}")
            return False

    def _archive_bztar(
        self,
        dist_dir: Path,
        output_file: Path,
        ignore_patterns: set[str],
    ) -> bool:
        """Create bzip2-compressed TAR archive."""
        try:
            logger.info(f"Creating bzip2-compressed TAR archive: {output_file}")
            output_file.parent.mkdir(parents=True, exist_ok=True)

            with tarfile.open(output_file, "w:bz2") as tar:
                for file_path in dist_dir.rglob("*"):
                    if file_path.is_file() and not self.should_ignore(
                        file_path, ignore_patterns
                    ):
                        arcname = file_path.relative_to(dist_dir)
                        tar.add(str(file_path), str(arcname))
                        if self.config.verbose:
                            logger.debug(f"Added: {arcname}")

            logger.info(
                f"Bzip2-compressed TAR archive created successfully: {output_file}"
            )
            return True
        except Exception as e:
            logger.error(f"Failed to create bzip2-compressed TAR archive: {e}")
            return False

    def _archive_xztar(
        self,
        dist_dir: Path,
        output_file: Path,
        ignore_patterns: set[str],
    ) -> bool:
        """Create xz-compressed TAR archive."""
        try:
            logger.info(f"Creating xz-compressed TAR archive: {output_file}")
            output_file.parent.mkdir(parents=True, exist_ok=True)

            with tarfile.open(output_file, "w:xz") as tar:
                for file_path in dist_dir.rglob("*"):
                    if file_path.is_file() and not self.should_ignore(
                        file_path, ignore_patterns
                    ):
                        arcname = file_path.relative_to(dist_dir)
                        tar.add(str(file_path), str(arcname))
                        if self.config.verbose:
                            logger.debug(f"Added: {arcname}")

            logger.info(
                f"XZ-compressed TAR archive created successfully: {output_file}"
            )
            return True
        except Exception as e:
            logger.error(f"Failed to create xz-compressed TAR archive: {e}")
            return False

    def _archive_zip(
        self,
        dist_dir: Path,
        output_file: Path,
        ignore_patterns: set[str],
    ) -> bool:
        """Create ZIP archive using Python's zipfile module."""
        try:
            logger.info(f"Creating ZIP archive: {output_file}")
            output_file.parent.mkdir(parents=True, exist_ok=True)

            # Map compression level to ZIP compression (0-9)
            compresslevel = max(0, min(9, self.config.compression_level))

            with zipfile.ZipFile(
                output_file, "w", zipfile.ZIP_DEFLATED, compresslevel=compresslevel
            ) as zipf:
                for file_path in dist_dir.rglob("*"):
                    if file_path.is_file() and not self.should_ignore(
                        file_path, ignore_patterns
                    ):
                        arcname = file_path.relative_to(dist_dir)
                        zipf.write(file_path, arcname)
                        if self.config.verbose:
                            logger.debug(f"Added: {arcname}")

            logger.info(f"ZIP archive created successfully: {output_file}")
            return True
        except Exception as e:
            logger.error(f"Failed to create ZIP archive: {e}")
            return False

    def _archive_7z(
        self,
        dist_dir: Path,
        output_file: Path,
        ignore_patterns: set[str],
    ) -> bool:
        """Create 7z archive using 7z command."""
        if not self.check_command_available(CMD_7Z):
            logger.error(f"{CMD_7Z} command not found. Please install 7-Zip.")
            return False

        try:
            logger.info(f"Creating 7z archive: {output_file}")
            output_file.parent.mkdir(parents=True, exist_ok=True)

            # Create temp directory with filtered files
            with tempfile.TemporaryDirectory(prefix=TEMP_DIR_PREFIX) as temp_dir:
                temp_path = Path(temp_dir) / dist_dir.name
                shutil.copytree(
                    dist_dir, temp_path, ignore=shutil.ignore_patterns(*ignore_patterns)
                )

                cmd = [CMD_7Z, "a", "-t7z", str(output_file), str(temp_path / "*")]
                subprocess.run(cmd, check=True, shell=True)

            logger.info(f"7z archive created successfully: {output_file}")
            return True
        except subprocess.CalledProcessError as e:
            logger.error(f"Failed to create 7z archive: {e}")
            return False
        except Exception as e:
            logger.error(f"Unexpected error creating 7z archive: {e}")
            return False

    def _generate_nsis_header(self, project: Project, output_file: Path) -> str:
        """Generate NSIS script header section."""
        # Extract major.minor.patch from version for VIProductVersion
        version_parts = project.version.split(".")
        if len(version_parts) >= 3:
            major, minor, patch = version_parts[0], version_parts[1], version_parts[2]
        elif len(version_parts) == 2:
            major, minor, patch = version_parts[0], version_parts[1], "0"
        else:
            major, minor, patch = version_parts[0], "0", "0"

        # Ensure description is not empty for NSIS
        description = (
            project.description
            if project.description
            else f"{project.name} installation package"
        )

        return f"""!include "{NSIS_INCLUDE_FILE}"

    Name "{project.name}"
    OutFile "{output_file.name}"
    InstallDir "{NSIS_PROGRAM_FILES}\\{project.name}"
    InstallDirRegKey HKCU "{NSIS_REGISTRY_KEY}\\{project.name}" ""
    RequestExecutionLevel admin
    VIProductVersion {major}.{minor}.{patch}.0
    VIAddVersionKey "ProductName" "{project.name}"
    VIAddVersionKey "ProductVersion" "{project.version}"
    VIAddVersionKey "FileDescription" "{description}"
    VIAddVersionKey "FileVersion" "{project.version}"
    VIAddVersionKey "LegalCopyright" "Copyright © {project.name} Development Team"
    """

    def _generate_nsis_pages(self) -> str:
        """Generate NSIS page definitions."""
        pages_content = "\n".join([f"!insertmacro {page}" for page in NSIS_PAGES])
        unpages_content = "\n".join([f"!insertmacro {page}" for page in NSIS_UNPAGES])

        return f"""!define {NSIS_ABORT_WARNING}
    {pages_content}

    {unpages_content}

    !insertmacro MUI_LANGUAGE "{NSIS_LANGUAGE}"
    """

    def _generate_nsis_sections(
        self, project: Project, source_dir_str: str, primary_exe: str
    ) -> str:
        """Generate NSIS section definitions."""
        return f"""Section "{project.name} ({NSIS_MAIN_SECTION})" SecMain
        SectionIn RO
        SetOutPath "{NSIS_INSTALL_DIR}"
        File /r "{source_dir_str}\\*.*"

        ; Write the uninstaller
        WriteUninstaller "$INSTDIR\\Uninstall.exe"

        ; Add registry keys for Add/Remove Programs
        WriteRegStr HKCU "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{project.name}" "DisplayName" "{project.name}"
        WriteRegStr HKCU "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{project.name}" "DisplayVersion" "{project.version}"
        WriteRegStr HKCU "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{project.name}" "DisplayIcon" "$INSTDIR\\exe.ico"
        WriteRegStr HKCU "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{project.name}" "UninstallString" "$INSTDIR\\Uninstall.exe"
        WriteRegStr HKCU "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{project.name}" "InstallLocation" "$INSTDIR"
        WriteRegStr HKCU "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{project.name}" "Publisher" "{project.name} Development Team"
    SectionEnd

    Section "{NSIS_SHORTCUT_SECTION}"
        CreateDirectory "$SMPROGRAMS\\{project.name}"
        ; Create shortcut for main executable using actual exe name
        ; Primary executable: {primary_exe}
        CreateShortcut "$SMPROGRAMS\\{project.name}\\{project.name}.lnk" "$INSTDIR\\{primary_exe}" "" "$INSTDIR\\exe.ico"
        CreateShortCut "$SMPROGRAMS\\{project.name}\\Uninstall.lnk" "$INSTDIR\\Uninstall.exe" "" "$WINDIR\\system32\\shell32.dll" 27
        ; Create desktop shortcut
        CreateShortCut "$DESKTOP\\{project.name}.lnk" "$INSTDIR\\{primary_exe}" "" "$INSTDIR\\exe.ico"
    SectionEnd

    Section "{NSIS_UNINSTALL_SECTION}"
        Delete "$INSTDIR\\*.*"
        Delete "$SMPROGRAMS\\{project.name}\\*.*"
        RMDir "$SMPROGRAMS\\{project.name}"
        RMDir /r "$INSTDIR"
        DeleteRegKey /ifempty HKCU "{NSIS_REGISTRY_KEY}\\{project.name}"
        ; Remove desktop shortcut on uninstall
        Delete "$DESKTOP\\{project.name}.lnk"
        ; Remove Add/Remove Programs registry keys
        DeleteRegKey HKCU "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{project.name}"
    SectionEnd
    """

    def _create_nsis_script(
        self,
        project: Project,
        dist_dir: Path,
        output_file: Path,
        primary_exe: str,
    ) -> Path | None:
        """Generate NSIS script file."""
        # Convert source_dir to use forward slashes for NSIS compatibility
        source_dir_str = str(dist_dir).replace("\\", "/")

        # Generate script components
        header = self._generate_nsis_header(project, output_file)
        pages = self._generate_nsis_pages()
        sections = self._generate_nsis_sections(project, source_dir_str, primary_exe)

        # Combine all parts
        nsis_script = f"""{header}
    {pages}
    {sections}"""

        script_file = output_file.parent / f"{project.name}_installer.nsi"
        try:
            with open(script_file, "w", encoding="utf-8") as f:
                f.write(nsis_script)
            logger.info(f"NSIS script generated: {script_file}")
            return script_file
        except Exception as e:
            logger.error(f"Failed to generate NSIS script: {e}")
            return None

    def _archive_nsis(
        self,
        project: Project,
        output_file: Path,
        ignore_patterns: set[str],
    ) -> bool:
        """Create NSIS installer using makensis command."""
        if not self.check_command_available(CMD_MAKENSIS):
            logger.error(f"{CMD_MAKENSIS} command not found. Please install NSIS.")
            return False

        # Check if primary executable exists
        primary_exe = project.primary_exe_name
        if not project.primary_exe.exists():
            exe_names = [p.name for p in project.exe_paths]
            logger.error(
                f"No project executable found in `{project.dist_dir}`. "
                f"Expected one of: {', '.join(exe_names)}. Cannot create NSIS installer."
            )
            return False

        logger.info(f"Creating NSIS installer: {output_file}")
        logger.info(f"Using primary executable: `{primary_exe}`")

        # Log additional executables if any
        if project.additional_exes:
            logger.debug(f"Additional executables found: {project.additional_exes}")

        output_file.parent.mkdir(parents=True, exist_ok=True)

        try:
            # Create temp directory with filtered files
            with tempfile.TemporaryDirectory(prefix=TEMP_DIR_PREFIX) as temp_dir:
                temp_path = Path(temp_dir) / project.dist_dir.name
                shutil.copytree(
                    project.dist_dir,
                    temp_path,
                    ignore=shutil.ignore_patterns(*ignore_patterns),
                )

                # Also copy LICENSE file from project root if it exists
                license_file = self.root_dir / "LICENSE"
                logger.debug(f"Looking for LICENSE file at: {license_file}")
                logger.debug(f"LICENSE file exists: {license_file.exists()}")
                if license_file.exists():
                    shutil.copy2(license_file, temp_path / "LICENSE")
                    logger.debug(
                        f"Copied LICENSE file to temporary directory: {temp_path / 'LICENSE'}"
                    )
                    logger.debug(
                        f"Temporary LICENSE file exists: {(temp_path / 'LICENSE').exists()}"
                    )

                # Convert and copy icon file for NSIS installer
                svg_icon = Path(__file__).parent / "assets" / "exe.svg"
                ico_icon = temp_path / "exe.ico"
                if svg_icon.exists():
                    try:
                        from sfi.img2ico.img2ico import ImageToIcoRunner

                        converter = ImageToIcoRunner(
                            input_path=svg_icon,
                            output_path=ico_icon,
                            sizes={16, 24, 32, 48, 64, 128, 256},
                        )
                        converter.run()
                        if ico_icon.exists():
                            logger.debug(f"Converted SVG icon to ICO: {ico_icon}")
                        else:
                            logger.warning("Icon conversion failed, using default")
                    except Exception as e:
                        logger.warning(f"Could not convert icon: {e}")
                else:
                    logger.debug(f"SVG icon not found: {svg_icon}")

                script_file = self._create_nsis_script(
                    project=project,
                    dist_dir=temp_path,
                    output_file=output_file,
                    primary_exe=primary_exe,
                )
                if not script_file or not script_file.exists():
                    return False

                cmd = [CMD_MAKENSIS, str(script_file)]
                subprocess.run(cmd, check=True, shell=True)

            logger.info(f"NSIS installer created successfully: {output_file}")
            return True
        except subprocess.CalledProcessError as e:
            logger.error(f"Failed to create NSIS installer: {e}")
            return False
        except Exception as e:
            logger.error(f"Unexpected error creating NSIS installer: {e}")
            return False


def list_supported_formats() -> str:
    """Generate a formatted list of supported formats with descriptions."""
    format_lines = []
    for fmt in ArchiveFormat:
        format_lines.append(f"  {fmt.value:<8} - {fmt.description}")
    return "\n".join(format_lines)


def create_parser() -> argparse.ArgumentParser:
    """Create parser for command line arguments."""
    parser = argparse.ArgumentParser(
        description="Archive projects in directory with various compression formats",
        epilog=f"Supported formats:\n{list_supported_formats()}",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "directory",
        type=Path,
        nargs="?",
        default=Path.cwd(),
        help="Directory to archive for projects.",
    )
    parser.add_argument("--debug", "-d", action="store_true", help="Debug mode")
    parser.add_argument(
        "--verbose", "-v", action="store_true", help="Verbose output during archiving"
    )
    parser.add_argument(
        "--format",
        "-f",
        type=str,
        default="zip",
        choices=sorted(ARCHIVE_FORMATS),
        help="Archive format (default: zip)",
    )
    parser.add_argument(
        "--compression-level",
        "-c",
        type=int,
        default=6,
        choices=range(0, 10),
        metavar="0-9",
        help="Compression level (0-9, default: 6)",
    )
    parser.add_argument(
        "--preserve-permissions",
        "-P",
        action="store_true",
        help="Preserve file permissions (TAR formats only)",
    )
    parser.add_argument(
        "--project",
        "-p",
        type=str,
        nargs="*",
        help="Specific project(s) to archive (default: all projects)",
    )
    parser.add_argument(
        "--ignore", type=str, nargs="*", help="Additional ignore patterns"
    )
    return parser


def main():
    parser = create_parser()
    args = parser.parse_args()

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

    # Create configuration from arguments
    config = PyArchiveConfig(
        compression_level=args.compression_level,
        verbose=args.verbose or args.debug,
        preserve_permissions=args.preserve_permissions,
    )

    # Create archiver instance
    archiver = PyArchiver(root_dir=args.directory, config=config)

    # Archive projects
    archiver.archive_projects(
        format=args.format,
        projects_to_archive=args.project,
        ignore_patterns=set(args.ignore or []),
    )
