"""Package Workflow - Advanced Python project packaging tool.

This module provides a comprehensive packaging solution that integrates pyprojectparse,
pysourcepack, pyembedinstall, and pyloadergen tools with configurable serial and
parallel execution for optimal efficiency.

The module follows established design patterns:
- Factory pattern for configuration creation
- Strategy pattern for cleaning operations
- Builder pattern for workflow construction
- Singleton pattern for logging configuration
"""

from __future__ import annotations

import argparse
import asyncio
import logging
import platform
import shutil
import subprocess
import time
from dataclasses import dataclass, field
from enum import Enum
from functools import cached_property
from pathlib import Path
from typing import Any, Callable, Protocol

from sfi.pyprojectparse.pyprojectparse import Project, Solution

logging.basicConfig(
    level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
cwd = Path.cwd()
is_windows = platform.system() == "Windows"
ext = ".exe" if is_windows else ""

__version__ = "1.0.0"
__build__ = "20260120"


# Enums for better type safety
class LoaderType(Enum):
    """Enumeration of supported loader types."""

    CONSOLE = "console"
    GUI = "gui"


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

    ZIP = "zip"
    SEVEN_ZIP = "7z"
    NSIS = "nsis"


class MirrorSource(Enum):
    """Enumeration of supported PyPI mirror sources."""

    PYPI = "pypi"
    TSINGHUA = "tsinghua"
    ALIYUN = "aliyun"
    USTC = "ustc"
    DOUBAN = "douban"
    TENCENT = "tencent"


# Protocol for cleaning strategies
class CleaningStrategy(Protocol):
    """Protocol for cleaning strategies."""

    def should_clean(self, entry: Path) -> bool:
        """Determine if an entry should be cleaned."""
        ...

    def clean_entry(self, entry: Path) -> tuple[bool, str]:
        """Clean an entry and return success status and message."""
        ...


# Constants
LOG_SEPARATOR = "=" * 50
PROTECTED_DIRS = {
    ".git",
    ".venv",
    ".virtualenv",
    ".vscode",
    ".idea",
    ".codebuddy",
    ".qoder",
}
CLEANABLE_DIRS = {"build", "dist", "pysfi_build", "cbuild", "benchmarks"}


# Configuration Factory Pattern
class ConfigFactory:
    """Factory for creating workflow configurations with validation."""

    @staticmethod
    def create_from_args(args: argparse.Namespace, cwd: Path) -> WorkflowConfig:
        """Create configuration from command line arguments."""
        # For commands that don't need project-specific config, use defaults
        project_name = getattr(args, "project", None)

        return WorkflowConfig(
            directory=cwd,
            project_name=project_name,
            python_version=getattr(args, "python_version", "3.8.10"),
            loader_type=LoaderType(getattr(args, "loader_type", "console")),
            entry_suffix=getattr(args, "entry_suffix", ".ent"),
            generate_loader=not getattr(args, "no_loader", False),
            offline=getattr(args, "offline", False),
            max_concurrent=getattr(args, "jobs", 4),
            debug=getattr(args, "debug", False),
            cache_dir=Path(args.cache_dir)
            if getattr(args, "cache_dir", None)
            else None,
            archive_format=ArchiveFormat(getattr(args, "archive_format", "zip")),
            mirror=MirrorSource(getattr(args, "mirror", "aliyun")),
            archive_type=getattr(args, "archive", None),
        )

    @staticmethod
    def create_default(directory: Path) -> WorkflowConfig:
        """Create default configuration."""
        return WorkflowConfig(directory=directory)


@dataclass
class WorkflowConfig:
    """Configuration for package workflow with type-safe enums."""

    directory: Path
    project_name: str | None = None
    python_version: str = "3.8.10"
    loader_type: LoaderType = LoaderType.CONSOLE
    entry_suffix: str = ".ent"
    generate_loader: bool = True
    offline: bool = False
    max_concurrent: int = 4
    debug: bool = False
    cache_dir: Path | None = None
    archive_format: ArchiveFormat = ArchiveFormat.ZIP
    mirror: MirrorSource = MirrorSource.ALIYUN
    archive_type: str | None = None

    @cached_property
    def normalized_directory(self) -> Path:
        """Get normalized and resolved directory path."""
        return self.directory.resolve()

    @cached_property
    def dist_dir(self) -> Path:
        """Get distribution directory path."""
        return self.normalized_directory / "dist"

    @cached_property
    def build_dir(self) -> Path:
        """Get build directory path."""
        return self.normalized_directory / "build"


# Strategy Pattern Implementation for Cleaning
class StandardCleaningStrategy:
    """Standard cleaning strategy implementation."""

    def should_clean(self, entry: Path) -> bool:
        """Determine if entry should be cleaned using standard rules."""
        # Special case: projects.json file should always be cleaned
        if entry.is_file():
            return entry.name == "projects.json"

        # Protected directories starting with dot
        if entry.name.startswith(".") and entry.name in PROTECTED_DIRS:
            return False

        # Clean temporary and build directories
        return (
            entry.name.startswith(".")
            or entry.name.startswith("__")
            or entry.name in CLEANABLE_DIRS
        )

    def clean_entry(self, entry: Path) -> tuple[bool, str]:
        """Clean entry and return success status with message."""
        if not entry.exists():
            return True, "Entry does not exist"

        try:
            if entry.is_dir():
                shutil.rmtree(entry)
                return True, f"Removed directory: {entry}"
            elif entry.is_file():
                entry.unlink()
                return True, f"Removed file: {entry}"
            else:
                return False, f"Unknown entry type: {entry}"
        except Exception as e:
            return False, f"Failed to remove {entry}: {e}"


@dataclass(frozen=True)
class PackageWorkflow:
    """Package workflow orchestrator using strategy pattern for cleaning."""

    root_dir: Path
    config: WorkflowConfig
    cleaning_strategy: CleaningStrategy = field(
        default_factory=StandardCleaningStrategy
    )

    @cached_property
    def solution(self) -> Solution:
        """Get the solution for the root directory.

        Returns:
            Solution: The solution for the root directory.
        """
        return Solution.from_directory(root_dir=self.root_dir)

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

        Returns:
            dict[str, Project]: The projects for the solution.
        """
        return self.solution.projects

    @property
    def dist_dir(self) -> Path:
        """Get distribution directory path."""
        return self.config.dist_dir

    @property
    def build_dir(self) -> Path:
        """Get build directory path."""
        return self.config.build_dir

    def clean_project(self) -> None:
        """Clean build artifacts and package files using strategy pattern."""
        logger.info("Cleaning build artifacts using strategy pattern...")

        entries_to_clean = [
            entry
            for entry in self.root_dir.iterdir()
            if self.cleaning_strategy.should_clean(entry)
        ]

        if not entries_to_clean:
            logger.info("No build artifacts found to clean")
            return

        # Track cleaning results
        cleaned_dirs: list[str] = []
        cleaned_files: list[str] = []
        failed_operations: list[str] = []

        for entry in entries_to_clean:
            success, message = self.cleaning_strategy.clean_entry(entry)
            if success:
                if entry.is_dir():
                    cleaned_dirs.append(str(entry))
                else:
                    cleaned_files.append(str(entry))
                logger.debug(message)
            else:
                failed_operations.append(message)
                logger.warning(message)

        # Summary logging
        logger.info(
            f"Cleaned {len(cleaned_dirs)} directories and {len(cleaned_files)} file(s)"
        )

        if failed_operations:
            logger.error(f"Failed operations: {len(failed_operations)}")

    async def _run_sync_task(self, name: str, setup_func: Callable[[], None]) -> None:
        """Run a synchronous task in thread pool executor.

        Args:
            name: Name of the task for logging
            setup_func: Function that returns the task to execute
        """
        logger.info(LOG_SEPARATOR)
        logger.info(f"Packing {name}...")

        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, setup_func)
        logger.info(f"{name.capitalize()} packed.")

    async def pack_embed_python(self) -> None:
        """Pack embed python."""
        from sfi.pyembedinstall.pyembedinstall import EmbedInstaller

        def _run():
            installer = EmbedInstaller(
                root_dir=self.root_dir,
                cache_dir=self.config.cache_dir,
                offline=self.config.offline,
            )
            installer.run()

        await self._run_sync_task("embed python", _run)

    async def pack_loaders(self) -> None:
        """Pack loaders for all projects concurrently."""
        from sfi.pyloadergen.pyloadergen import PyLoaderGenerator

        def _run():
            generator = PyLoaderGenerator(root_dir=self.root_dir)
            generator.run()

        await self._run_sync_task("loaders", _run)

    async def pack_libraries(self) -> None:
        """Pack libraries for all projects concurrently."""
        from sfi.pylibpack.pylibpack import PyLibPacker, PyLibPackerConfig

        def _run():
            libpacker = PyLibPacker(
                working_dir=self.root_dir,
                config=PyLibPackerConfig(self.config.cache_dir),
            )
            libpacker.run()

        await self._run_sync_task("libraries", _run)

    async def pack_source(self) -> None:
        """Pack source code for all projects concurrently."""
        from sfi.pysourcepack.pysourcepack import PySourcePacker

        def _run():
            source_packer = PySourcePacker(root_dir=self.root_dir)
            source_packer.run()

        await self._run_sync_task("source code", _run)

    async def pack_archive(self, archive_format: str) -> None:
        """Create archive for all projects.

        Args:
            archive_format: Archive format (zip, tar, gztar, bztar, xztar, 7z, nsis)
        """
        from sfi.pyarchive.pyarchive import PyArchiveConfig, PyArchiver

        def _run():
            config = PyArchiveConfig(verbose=self.config.debug)
            archiver = PyArchiver(root_dir=self.root_dir, config=config)
            archiver.archive_projects(format=archive_format)

        await self._run_sync_task(f"{archive_format} archives", _run)

    async def build(self) -> dict[str, Any]:
        """Execute the packaging workflow with concurrent optimization.

        Workflow stages:
        1. Pack embed python (must be first)
        2. Pack loaders, libraries, and source in parallel
        3. Create archive (optional, if archive_type is set)

        Returns:
            Dict with results and summary including output_dir and metadata

        Raises:
            FileNotFoundError: If required directories don't exist
            RuntimeError: If any packaging step fails
        """
        logger.info("Starting packaging workflow execution")
        start_time = time.perf_counter()

        try:
            # Stage 1: Pack embed python (prerequisite for other tasks)
            await self.pack_embed_python()

            # Stage 2: Pack loaders, libraries, and source concurrently
            logger.info(LOG_SEPARATOR)
            logger.info("Running parallel tasks: loaders, libraries, source...")
            await asyncio.gather(
                self.pack_loaders(),
                self.pack_libraries(),
                self.pack_source(),
            )

            # Stage 3: Create archive (optional)
            if self.config.archive_type:
                await self.pack_archive(self.config.archive_type)

        except (FileNotFoundError, RuntimeError):
            raise
        except Exception as e:
            logger.error(f"Workflow execution failed: {e}")
            raise RuntimeError(f"Packaging workflow failed: {e}") from e

        elapsed = time.perf_counter() - start_time
        logger.info(LOG_SEPARATOR)
        logger.info(f"Packaging workflow completed in {elapsed:.2f}s")
        return {"output_dir": str(self.dist_dir), "metadata": {}}

    def list_projects(self) -> None:
        """List all available projects."""
        logger.info(f"Listing projects in {self.root_dir}")
        for project in self.projects.values():
            logger.info(f"  - {project}")

    def _scan_executables(self) -> list[Path]:
        """Scan dist directory for executable files.

        Returns:
            List of executable file paths found in dist directory.
        """
        dist_dir = self.dist_dir
        if not dist_dir.exists():
            return []
        return [f for f in dist_dir.glob(f"*{ext}") if f.is_file()]

    def _resolve_executable(self, match_name: str | None) -> Path | None:
        """Resolve executable by scanning dist directory and matching name.

        Args:
            match_name: Executable name or partial name to match

        Returns:
            Path to matched executable, or None if not found/ambiguous.
        """
        executables = self._scan_executables()
        if not executables:
            return None

        # Auto-select if only one executable and no name specified
        if not match_name:
            return executables[0] if len(executables) == 1 else None

        lower_name = match_name.lower()

        # Try exact match (without extension)
        for exe in executables:
            if exe.stem.lower() == lower_name:
                return exe

        # Try fuzzy match (case-insensitive substring)
        matches = [exe for exe in executables if lower_name in exe.stem.lower()]
        return matches[0] if len(matches) == 1 else None

    def list_executables(self) -> None:
        """List all executables in dist directory."""
        executables = self._scan_executables()
        if not executables:
            logger.info("No executables found in dist directory")
            return
        logger.info(f"Available executables in {self.dist_dir}:")
        for exe in executables:
            logger.info(f"  - {exe.stem}")

    def run_project(
        self, match_name: str | None, project_args: list[str] | None = None
    ) -> None:
        """Run an executable with fuzzy name matching support.

        Args:
            match_name: Executable name or partial name to match
            project_args: Additional arguments to pass to the executable
        """
        exe_path = self._resolve_executable(match_name)

        # Handle executable not found cases
        if not exe_path:
            self._handle_executable_not_found(match_name)
            return

        # Build and execute command
        cmd = self._build_executable_command(exe_path, project_args)

        try:
            subprocess.run(cmd, check=True, cwd=self.root_dir)
            logger.info(f"{exe_path.stem} ran successfully")
        except subprocess.CalledProcessError as e:
            logger.error(f"{exe_path.stem} failed with exit code {e.returncode}")
        except FileNotFoundError:
            logger.error(f"Executable not found: {exe_path}")
        except Exception as e:
            logger.error(f"Failed to run {exe_path}: {e}")

    def _handle_executable_not_found(self, match_name: str | None) -> None:
        """Handle cases where executable cannot be found.

        Args:
            match_name: Executable name that was being searched for
        """
        executables = self._scan_executables()

        if not match_name:
            if len(executables) == 0:
                logger.error("No executables found in dist directory")
            elif len(executables) > 1:
                logger.error(
                    "Multiple executables found. Please specify which one to run:"
                )
                self.list_executables()
            else:
                logger.error("Unable to auto-select executable")
        else:
            logger.error(f"Executable '{match_name}' not found")

        if len(executables) > 0:
            self.list_executables()

    def _build_executable_command(
        self, exe_path: Path, project_args: list[str] | None
    ) -> list[str]:
        """Build command list for executable execution.

        Args:
            exe_path: Path to executable
            project_args: Additional arguments to pass

        Returns:
            List of command arguments
        """
        cmd = [str(exe_path.resolve())]
        if project_args:
            cmd.extend(project_args)
            logger.info(f"Arguments: {' '.join(project_args)}")
        logger.info(f"Running: {' '.join(cmd)}")
        return cmd


def parse_args() -> argparse.Namespace:
    """Parse command line arguments using subcommand structure.

    Returns:
        Parsed arguments namespace
    """
    parser = argparse.ArgumentParser(
        prog="pypack", description="Python packaging tool with workflow orchestration"
    )

    # Add common arguments to parent parser
    parent_parser = argparse.ArgumentParser(add_help=False)
    parent_parser.add_argument("--debug", "-d", action="store_true", help="Debug mode")

    # Create subparsers
    subparsers = parser.add_subparsers(
        dest="command", required=True, help="Available commands"
    )

    # Version subcommand
    subparsers.add_parser(
        "version",
        aliases=["v"],
        help="Show version information",
        parents=[parent_parser],
    )

    # List subcommand
    subparsers.add_parser(
        "list",
        aliases=["l", "ls"],
        help="List available projects",
        parents=[parent_parser],
    )

    # Clean subcommand
    subparsers.add_parser(
        "clean", aliases=["c"], help="Clean build artifacts", parents=[parent_parser]
    )

    # Run subcommand
    run_parser = subparsers.add_parser(
        "run", aliases=["r"], help="Run a project", parents=[parent_parser]
    )
    run_parser.add_argument(
        "project",
        type=str,
        nargs="?",
        help="Project or executable name (e.g., 'docscan' or 'docscan-gui')",
    )
    run_parser.add_argument(
        "args",
        type=str,
        nargs="*",
        help="Additional arguments to pass to the project",
    )

    # Build subcommand
    build_parser = subparsers.add_parser(
        "build", aliases=["b"], help="Build project packages", parents=[parent_parser]
    )
    build_parser.add_argument(
        "--python-version", type=str, default="3.8.10", help="Python version to install"
    )
    build_parser.add_argument(
        "--loader-type",
        type=str,
        choices=[lt.value for lt in LoaderType],
        default=LoaderType.CONSOLE.value,
        help="Loader type",
    )
    build_parser.add_argument(
        "--entry-suffix",
        type=str,
        default=".ent",
        help="Entry file suffix (default: .ent, alternatives: .py)",
    )
    build_parser.add_argument(
        "--no-loader", action="store_true", help="Skip loader generation"
    )
    build_parser.add_argument(
        "-o", "--offline", action="store_true", help="Offline mode"
    )
    build_parser.add_argument(
        "-j", "--jobs", type=int, default=4, help="Maximum concurrent tasks"
    )

    # Library packing arguments (build-specific)
    build_parser.add_argument(
        "--cache-dir",
        type=str,
        default=None,
        help="Custom cache directory for dependencies",
    )
    build_parser.add_argument(
        "--archive-format",
        type=str,
        choices=[af.value for af in ArchiveFormat],
        default=ArchiveFormat.ZIP.value,
        help="Archive format for dependencies",
    )
    build_parser.add_argument(
        "--mirror",
        type=str,
        default=MirrorSource.ALIYUN.value,
        choices=[ms.value for ms in MirrorSource],
        help="PyPI mirror source for faster downloads",
    )

    # Archive options (optional post-build step)
    build_parser.add_argument(
        "--archive",
        "-a",
        type=str,
        nargs="?",
        const="zip",
        default=None,
        choices=["zip", "tar", "gztar", "bztar", "xztar", "7z", "nsis"],
        help="Create archive after build (default format: zip if flag used)",
    )

    return parser.parse_args()


def main() -> None:
    """Main entry point for package workflow tool using factory pattern."""
    args = parse_args()

    # Configure logging level
    if args.debug:
        logging.getLogger().setLevel(logging.DEBUG)

    # Build workflow configuration using factory pattern
    config = ConfigFactory.create_from_args(args, cwd)

    workflow = PackageWorkflow(
        root_dir=config.directory,
        config=config,
        cleaning_strategy=StandardCleaningStrategy(),
    )

    # Command dispatch using pattern matching
    command = args.command

    if command in ("version", "v"):
        logger.info(f"pypack {__version__} (build {__build__})")
    elif command in ("list", "l", "ls"):
        workflow.list_projects()
    elif command in ("run", "r"):
        workflow.run_project(args.project, args.args)
    elif command in ("clean", "c"):
        workflow.clean_project()
    elif command in ("build", "b"):
        try:
            asyncio.run(workflow.build())
            logger.info("Packaging completed successfully!")
        except Exception as e:
            logger.error(f"Packaging failed: {e}")
            raise


if __name__ == "__main__":
    main()
