"""Build Manager for C++ API Dynamic Module System.

This module provides centralized build logic with features:
- Generator version tracking (hash-based)
- Module change detection
- Backup/rollback on failed builds
- Progress reporting
- Build validation
"""

import sys
import os
import subprocess
import hashlib
import shutil
from pathlib import Path
from typing import List, Optional, Callable, Dict

from .exceptions import CppBuildError, CppValidationError, CppGeneratorError


class BuildManager:
    """Manages building and rebuilding of C++ modules.

    Features:
    - Tracks plugin generator version to detect when it needs rebuilding
    - Detects which modules have changed since last build
    - Backs up existing .pyd before rebuild
    - Rolls back to backup if build fails
    - Validates built module can be imported
    - Reports progress via optional callback
    """

    def __init__(self, project_root: Path):
        """Initialize BuildManager.

        Args:
            project_root: Root directory of the project
        """
        self.project_root = project_root
        self.plugins_dir = project_root / "src" / "plugins"
        self.module_dir = project_root / "src" / "module"
        self.include_dir = project_root / "src" / "include"
        self.python_dir = project_root / "src" / "python"
        self.bin_dir = project_root / "bin" / ".appc"
        self.build_dir = project_root / "build"

        self.gen_exe = self.bin_dir / "plugin_gen.exe"
        self.gen_version_file = self.bin_dir / ".version"
        self.registry_file = project_root / ".module_registry.json"

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

    def _compute_hash(self, filepath: Path) -> str:
        """Compute SHA256 hash of file.

        Args:
            filepath: Path to file

        Returns:
            Hex string of hash, or "0" if file doesn't exist
        """
        if not filepath.exists():
            return "0"

        try:
            with open(filepath, 'rb') as f:
                return hashlib.sha256(f.read()).hexdigest()
        except Exception:
            return "0"

    def _hash_files(self, files: List[Path]) -> str:
        """Compute combined hash of multiple files.

        Args:
            files: List of file paths

        Returns:
            Combined hash as hex string
        """
        combined = ""
        for f in files:
            combined += self._compute_hash(f)
        return hashlib.sha256(combined.encode()).hexdigest()

    def check_generator_changed(self) -> bool:
        """Check if plugin generator source changed since last build.

        Returns:
            True if generator needs rebuilding
        """
        api_cpp = self.include_dir / "api.cpp"
        api_h = self.include_dir / "api.h"

        if not api_cpp.exists() or not api_h.exists():
            return True

        current_hash = self._hash_files([api_cpp, api_h])

        if not self.gen_version_file.exists():
            return True

        try:
            stored_hash = self.gen_version_file.read_text().strip()
            return current_hash != stored_hash
        except Exception:
            return True

    def _save_generator_version(self):
        """Save current generator version hash."""
        api_cpp = self.include_dir / "api.cpp"
        api_h = self.include_dir / "api.h"

        current_hash = self._hash_files([api_cpp, api_h])

        try:
            self.gen_version_file.write_text(current_hash)
        except Exception as e:
            raise CppBuildError(f"Failed to save generator version: {e}")

    def detect_changed_modules(self, registry: dict) -> List[str]:
        """Detect which modules have changed since last build.

        Args:
            registry: Module registry dict from .module_registry.json

        Returns:
            List of module names that have changed
        """
        changed = []

        for module_name, info in registry.items():
            # Check source file
            sources = info.get('sources', [])
            if sources:
                source_path = self.project_root / sources[0]
                source_hash = self._compute_hash(source_path)
                stored_hash = info.get('hashes', {}).get(f"{module_name}.cpp", "")
                if source_hash != stored_hash:
                    changed.append(module_name)
                    continue

            # Check header file
            header = info.get('header')
            if header:
                header_path = self.project_root / header
                header_hash = self._compute_hash(header_path)
                stored_hash = info.get('hashes', {}).get(f"{module_name}.h", "")
                if header_hash != stored_hash:
                    changed.append(module_name)
                    continue

            # Check .cp file
            cp_file = info.get('cp_file')
            if cp_file:
                cp_path = Path(cp_file)
                cp_hash = self._compute_hash(cp_path)
                stored_hash = info.get('hashes', {}).get(f"{module_name}.cp", "")
                if cp_hash != stored_hash:
                    changed.append(module_name)

        return changed

    def rebuild_generator(self, verbose: bool = False) -> bool:
        """Rebuild plugin generator if needed.

        Args:
            verbose: Print detailed output

        Returns:
            True if rebuild succeeded (or wasn't needed)

        Raises:
            CppGeneratorError: If generator build fails
        """
        if not self.check_generator_changed():
            if verbose:
                print("Plugin generator is up to date")
            return True

        if verbose:
            print("Rebuilding plugin generator...")

        api_cpp = self.include_dir / "api.cpp"

        # Try g++ first
        try:
            cmd = [
                "g++",
                "-std=c++17",
                "-O2",
                f"-I{self.include_dir}",
                str(api_cpp),
                "-o",
                str(self.gen_exe)
            ]

            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=60
            )

            if result.returncode == 0 and self.gen_exe.exists():
                self._save_generator_version()
                if verbose:
                    print(f"✓ Generator rebuilt: {self.gen_exe}")
                return True

        except FileNotFoundError:
            pass  # g++ not found, try MSVC

        # Try MSVC
        try:
            cmd = [
                "cl",
                "/nologo",
                "/EHsc",
                "/std:c++17",
                "/O2",
                f"/I{self.include_dir}",
                f"/Fe:{self.gen_exe}",
                str(api_cpp)
            ]

            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=60
            )

            if result.returncode == 0 and self.gen_exe.exists():
                self._save_generator_version()
                if verbose:
                    print(f"✓ Generator rebuilt: {self.gen_exe}")
                return True

        except FileNotFoundError:
            pass

        raise CppGeneratorError(
            "Failed to build plugin generator. "
            "No C++ compiler found (tried g++ and cl.exe)"
        )

    def _find_pyd_file(self) -> Optional[Path]:
        """Find the built api.pyd file.

        Returns:
            Path to .pyd file, or None if not found
        """
        search_paths = [
            self.build_dir / "Release",
            self.project_root / "bin" / ".dist" / "Release",
            self.project_root / "bin" / ".dist",
            self.build_dir
        ]

        for path in search_paths:
            if path.exists():
                for pyd_file in path.glob("api*.pyd"):
                    return pyd_file

        return None

    def _backup_pyd(self) -> Optional[Path]:
        """Backup current api.pyd before rebuild.

        Returns:
            Path to backup file, or None if no .pyd exists
        """
        # Find existing .pyd in python dir
        existing_pyds = list(self.python_dir.glob("api*.pyd"))

        if not existing_pyds:
            return None

        pyd_path = existing_pyds[0]
        backup_path = pyd_path.with_suffix('.pyd.backup')

        try:
            shutil.copy2(pyd_path, backup_path)
            return backup_path
        except Exception as e:
            raise CppBuildError(f"Failed to backup .pyd file: {e}")

    def _rollback_pyd(self):
        """Restore backup if build failed."""
        backup_files = list(self.python_dir.glob("api*.pyd.backup"))

        if not backup_files:
            return

        backup_path = backup_files[0]
        target_path = backup_path.with_suffix('')

        try:
            shutil.copy2(backup_path, target_path)
        except Exception:
            pass  # Rollback is best-effort

    def _remove_backup(self):
        """Remove backup file after successful build."""
        backup_files = list(self.python_dir.glob("api*.pyd.backup"))

        for backup in backup_files:
            try:
                backup.unlink()
            except Exception:
                pass

    def _validate_module(self) -> None:
        """Validate the built module can be imported.

        Raises:
            CppValidationError: If module cannot be imported
        """
        # Try importing in subprocess to avoid corrupting current process
        test_script = f"""
import sys
sys.path.insert(0, r'{self.python_dir}')
try:
    import api
    assert hasattr(api, '__doc__'), "Module has no __doc__ attribute"
    print("OK")
except Exception as e:
    print(f"FAIL: {{e}}")
    sys.exit(1)
"""

        try:
            result = subprocess.run(
                [sys.executable, "-c", test_script],
                capture_output=True,
                text=True,
                timeout=10
            )

            if result.returncode != 0 or "OK" not in result.stdout:
                raise CppValidationError(
                    f"Module validation failed:\n{result.stderr}\n{result.stdout}"
                )

        except subprocess.TimeoutExpired:
            raise CppValidationError("Module validation timed out")

    def rebuild_modules(self,
                       module_names: Optional[List[str]] = None,
                       progress_callback: Optional[Callable[[int, int, str], None]] = None) -> bool:
        """Rebuild C++ modules.

        For monolithic build (single api.pyd), always rebuilds everything.
        Future: Could support per-module .pyd files for true incremental builds.

        Args:
            module_names: List of module names to rebuild (currently ignored - full rebuild)
            progress_callback: Optional callback(step, total, description)

        Returns:
            True if rebuild succeeded

        Raises:
            CppBuildError: If any build step fails
        """
        steps = [
            ("Ensuring generator is built", self._step_ensure_generator),
            ("Generating bindings", self._step_generate_bindings),
            ("Backing up existing build", self._step_backup),
            ("Configuring CMake", self._step_configure_cmake),
            ("Building C++", self._step_build_cpp),
            ("Installing module", self._step_install),
            ("Validating module", self._step_validate),
            ("Cleaning up", self._step_cleanup)
        ]

        backup_path = None

        try:
            for i, (description, func) in enumerate(steps):
                if progress_callback:
                    progress_callback(i, len(steps), description)

                result = func()
                if description == "Backing up existing build":
                    backup_path = result

        except Exception as e:
            # Rollback on failure
            if backup_path:
                self._rollback_pyd()
            raise CppBuildError(f"Build failed at step '{description}': {e}") from e

        return True

    def _step_ensure_generator(self):
        """Step: Ensure generator is built."""
        if not self.gen_exe.exists() or self.check_generator_changed():
            self.rebuild_generator(verbose=False)

    def _step_generate_bindings(self):
        """Step: Run plugin generator."""
        bindings_cpp = self.module_dir / "bindings.cpp"
        sources_txt = self.module_dir / "sources.txt"

        cmd = [
            str(self.gen_exe),
            str(self.plugins_dir),
            str(bindings_cpp),
            str(sources_txt),
            str(self.registry_file)
        ]

        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=60
        )

        if result.returncode != 0:
            raise CppGeneratorError(
                f"Plugin generator failed:\n{result.stderr}"
            )

    def _step_backup(self):
        """Step: Backup existing .pyd."""
        return self._backup_pyd()

    def _step_configure_cmake(self):
        """Step: Configure CMake."""
        if self.build_dir.exists():
            shutil.rmtree(self.build_dir)
        self.build_dir.mkdir(exist_ok=True)

        cmake_cmd = [
            "cmake",
            "-B", str(self.build_dir),
            "-S", str(self.project_root),
            f"-DPYTHON_EXECUTABLE={sys.executable}"
        ]

        # Try different generators
        for generator in [
            ["-G", "Visual Studio 17 2022", "-A", "x64"],
            ["-G", "Visual Studio 16 2019", "-A", "x64"],
            ["-G", "MinGW Makefiles"],
            []
        ]:
            try:
                full_cmd = cmake_cmd + generator
                result = subprocess.run(
                    full_cmd,
                    capture_output=True,
                    text=True,
                    timeout=120
                )

                if result.returncode == 0:
                    return

            except (FileNotFoundError, subprocess.TimeoutExpired):
                continue

        raise CppBuildError("CMake configuration failed with all generators")

    def _step_build_cpp(self):
        """Step: Build with CMake."""
        cmd = ["cmake", "--build", str(self.build_dir), "--config", "Release"]

        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=300
        )

        if result.returncode != 0:
            raise CppBuildError(f"C++ build failed:\n{result.stderr}")

    def _step_install(self):
        """Step: Install .pyd to Python directory."""
        pyd_file = self._find_pyd_file()

        if not pyd_file:
            raise CppBuildError("Built .pyd file not found")

        dest = self.python_dir / pyd_file.name

        try:
            shutil.copy2(pyd_file, dest)
        except Exception as e:
            raise CppBuildError(f"Failed to install .pyd: {e}")

    def _step_validate(self):
        """Step: Validate module."""
        self._validate_module()

    def _step_cleanup(self):
        """Step: Remove backup."""
        self._remove_backup()
