"""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 atexit
import enum
import json
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 TYPE_CHECKING, Any, Final

from pytola.core.logger import get_logger

from ..models.solution import Solution

if TYPE_CHECKING:
    from ..models.project import Project

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

logger = get_logger(__name__)

# Configuration constants
CONFIG_FILE: Final[Path] = Path.home() / ".pytola" / "pyarchive.json"
DEFAULT_CACHE_DIR: Final[Path] = Path.home() / ".pytola" / ".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"


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


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


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


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


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:
            msg = f"Compression level must be between {MIN_COMPRESSION_LEVEL} and {MAX_COMPRESSION_LEVEL}"
            raise ValueError(
                msg,
            )

    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:
            msg = f"Compression level must be between {MIN_COMPRESSION_LEVEL} and {MAX_COMPRESSION_LEVEL}"
            raise ValueError(
                msg,
            )


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
        # Check full path
        elif 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 Path(config_file).open(encoding="utf-8") as f:
            return json.load(f)
    except json.JSONDecodeError as e:
        logger.exception(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
            # Check full path
            elif 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:
            msg = "Project name cannot be empty"
            raise ValueError(msg)

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

        # 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.exception(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 Path(file_path).open("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.exception(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.exception(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.exception(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.exception(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.exception(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.exception(f"Failed to create 7z archive: {e}")
            return False
        except Exception as e:
            logger.exception(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 or 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
        !define DISPLAY_NAME "{project.name}"
        !define DISPLAY_VERSION "{project.version}"
        !define DISPLAY_ICON "$INSTDIR\\exe.ico"
        !define UNINSTALL_STRING "$INSTDIR\\Uninstall.exe"
        !define INSTALL_LOCATION "$INSTDIR"
        !define PUBLISHER "{project.name} Development Team"

        !define UNINST_ROOT "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{project.name}"
        !macro WriteUninstallReg
        WriteRegStr HKCU $(UNINST_ROOT) "DisplayName" $(DISPLAY_NAME)
        WriteRegStr HKCU $(UNINST_ROOT) "DisplayVersion" $(DISPLAY_VERSION)
        WriteRegStr HKCU $(UNINST_ROOT) "DisplayIcon" $(DISPLAY_ICON)
        WriteRegStr HKCU $(UNINST_ROOT) "UninstallString" $(UNINSTALL_STRING)
        WriteRegStr HKCU $(UNINST_ROOT) "InstallLocation" $(INSTALL_LOCATION)
        WriteRegStr HKCU $(UNINST_ROOT) "Publisher" $(PUBLISHER)
        !macroend
        !insertmacro WriteUninstallReg
        !undef UNINST_ROOT

        !undef DISPLAY_NAME
        !undef DISPLAY_VERSION
        !undef DISPLAY_ICON
        !undef UNINSTALL_STRING
        !undef INSTALL_LOCATION
        !undef PUBLISHER
    SectionEnd

    Section "{NSIS_SHORTCUT_SECTION}"
        CreateDirectory "$SMPROGRAMS\\{project.name}"
        ; Create shortcut for main executable using actual exe name
        ; Primary executable: {primary_exe}
        !define PROGRAM_LINK "{project.name}"
        !define PRIMARY_EXE "{primary_exe}"
        !define UNINSTALL_LNK "Uninstall.lnk"
        !define SHELL_DLL "$WINDIR\\system32\\shell32.dll"

        !define START_MENU "$SMPROGRAMS\\{project.name}"
        !define INST_DIR "$INSTDIR"
        !macro CreateShortcuts
        CreateShortcut $(START_MENU)\\$(PROGRAM_LINK).lnk $(INST_DIR)\\$(PRIMARY_EXE) "" $(INST_DIR)\\exe.ico
        CreateShortCut $(START_MENU)\\$(UNINSTALL_LNK) $(INST_DIR)\\Uninstall.exe "" $(SHELL_DLL) 27
        !macroend
        !insertmacro CreateShortcuts
        !undef START_MENU
        !undef INST_DIR

        !undef PROGRAM_LINK
        !undef PRIMARY_EXE
        !undef UNINSTALL_LNK
        !undef SHELL_DLL
        ; 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:
            Path(script_file).write_text(nsis_script, encoding="utf-8")
            logger.info(f"NSIS script generated: {script_file}")
            return script_file
        except Exception as e:
            logger.exception(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" / "icons" / "exe.svg"
                ico_icon = temp_path / "exe.ico"
                if svg_icon.exists():
                    try:
                        from pytola.office.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.exception(f"Failed to create NSIS installer: {e}")
            return False
        except Exception as e:
            logger.exception(f"Unexpected error creating NSIS installer: {e}")
            return False
