"""
Local Process Manager for Claude MPM Framework
==============================================

WHY: Provides reliable process lifecycle management for local deployments
with process isolation, port conflict prevention, and graceful shutdown.

DESIGN DECISION: Uses subprocess.Popen for direct process control with
process groups for clean termination. Integrates with DeploymentStateManager
for persistent tracking.

ARCHITECTURE:
- Process group isolation (start_new_session=True on Unix)
- Port conflict detection using psutil
- Linear probing for alternative ports
- Protected port range enforcement
- Graceful shutdown with timeout and force kill fallback

USAGE:
    state_manager = DeploymentStateManager(state_file_path)
    process_manager = LocalProcessManager(state_manager)

    config = StartConfig(
        command=["npm", "run", "dev"],
        working_directory="/path/to/project",
        port=3000
    )

    deployment = process_manager.start(config)
    process_manager.stop(deployment.deployment_id)
"""

import os
import platform
import signal
import subprocess
import time
from datetime import UTC, datetime
from hashlib import sha256
from pathlib import Path

import psutil

from claude_mpm.core.enums import ServiceState
from claude_mpm.services.core.base import SyncBaseService
from claude_mpm.services.core.interfaces.process import (
    IDeploymentStateManager,
    ILocalProcessManager,
)
from claude_mpm.services.core.models.process import (
    DeploymentState,
    ProcessInfo,
    StartConfig,
    is_port_protected,
)


class ProcessSpawnError(Exception):
    """Raised when process cannot be spawned."""


class PortConflictError(Exception):
    """Raised when requested port is unavailable and no alternative found."""


class LocalProcessManager(SyncBaseService, ILocalProcessManager):
    """
    Manages local process lifecycle with isolation and state tracking.

    WHY: Provides high-level process management operations that handle
    all the complexity of spawning, tracking, and terminating background
    processes reliably.

    Thread Safety: Operations are thread-safe through state manager locking.
    """

    def __init__(self, state_manager: IDeploymentStateManager):
        """
        Initialize process manager.

        Args:
            state_manager: State manager for deployment persistence
        """
        super().__init__("LocalProcessManager")
        self.state_manager = state_manager
        self.is_windows = platform.system() == "Windows"

    def initialize(self) -> bool:
        """
        Initialize the process manager.

        Returns:
            True if initialization successful
        """
        try:
            # Ensure state manager is initialized
            if not self.state_manager.is_initialized:
                if not self.state_manager.initialize():
                    self.log_error("Failed to initialize state manager")
                    return False

            self._initialized = True
            self.log_info("Process manager initialized")
            return True

        except Exception as e:
            self.log_error(f"Failed to initialize: {e}")
            return False

    def shutdown(self) -> None:
        """Shutdown process manager (processes continue running)."""
        self._shutdown = True
        self.log_info("Process manager shutdown complete")

    def start(self, config: StartConfig) -> DeploymentState:
        """
        Start a new background process.

        WHY: Combines process spawning, port allocation, and state tracking
        in a single atomic operation.

        Args:
            config: Configuration for the process to start

        Returns:
            DeploymentState with process information

        Raises:
            ProcessSpawnError: If process cannot be spawned
            PortConflictError: If port unavailable and no alternative found
            ValueError: If configuration is invalid
        """
        # Validate working directory exists
        working_dir = Path(config.working_directory)
        if not working_dir.exists():
            raise ValueError(f"Working directory does not exist: {working_dir}")

        # Handle port allocation if needed
        allocated_port = None
        if config.port is not None:
            allocated_port = self._allocate_port(config.port, config.auto_find_port)

        # Generate deployment ID if not provided
        project_name = working_dir.name
        deployment_id = config.deployment_id or self.generate_deployment_id(
            project_name, allocated_port
        )

        # Prepare environment variables
        env = os.environ.copy()
        env.update(config.environment)
        if allocated_port is not None:
            env["PORT"] = str(allocated_port)

        # Spawn the process
        try:
            self.log_info(
                f"Spawning process for {deployment_id}: {' '.join(config.command)}"
            )

            # Platform-specific process group creation
            if self.is_windows:
                # Windows: use CREATE_NEW_PROCESS_GROUP
                process = subprocess.Popen(
                    config.command,
                    cwd=str(working_dir),
                    env=env,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
                )
            else:
                # Unix: use start_new_session for process group isolation
                process = subprocess.Popen(
                    config.command,
                    cwd=str(working_dir),
                    env=env,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    start_new_session=True,
                )

            # Give process a moment to start
            time.sleep(0.5)

            # Check if process is still running
            if process.poll() is not None:
                # Process died immediately
                _stdout, stderr = process.communicate()
                error_msg = stderr.decode("utf-8", errors="replace") if stderr else ""
                raise ProcessSpawnError(
                    f"Process died immediately. Exit code: {process.returncode}. "
                    f"Error: {error_msg}"
                )

            # Create deployment state
            deployment = DeploymentState(
                deployment_id=deployment_id,
                process_id=process.pid,
                command=config.command,
                working_directory=str(working_dir),
                environment=config.environment,
                port=allocated_port,
                started_at=datetime.now(tz=UTC),
                status=ServiceState.RUNNING,
                metadata=config.metadata,
            )

            # Save to state
            self.state_manager.add_deployment(deployment)

            self.log_info(
                f"Started process {process.pid} for {deployment_id} "
                f"on port {allocated_port or 'N/A'}"
            )

            return deployment

        except subprocess.SubprocessError as e:
            raise ProcessSpawnError(f"Failed to spawn process: {e}") from e

    def stop(self, deployment_id: str, timeout: int = 10, force: bool = False) -> bool:
        """
        Stop a running process.

        WHY: Provides graceful shutdown with configurable timeout and
        force kill fallback for stuck processes.

        Args:
            deployment_id: Unique deployment identifier
            timeout: Seconds to wait for graceful shutdown
            force: If True, kill immediately without waiting

        Returns:
            True if process stopped successfully

        Raises:
            ValueError: If deployment_id not found
        """
        deployment = self.state_manager.get_deployment(deployment_id)
        if not deployment:
            raise ValueError(f"Deployment not found: {deployment_id}")

        try:
            process = psutil.Process(deployment.process_id)
        except psutil.NoSuchProcess:
            # Process already dead, just update state
            self.log_info(f"Process {deployment.process_id} already dead")
            self.state_manager.update_deployment_status(
                deployment_id, ServiceState.STOPPED
            )
            return True

        self.log_info(f"Stopping process {deployment.process_id} for {deployment_id}")
        self.state_manager.update_deployment_status(
            deployment_id, ServiceState.STOPPING
        )

        try:
            if force:
                # Force kill immediately
                self._kill_process_group(process)
                self.state_manager.update_deployment_status(
                    deployment_id, ServiceState.STOPPED
                )
                return True

            # Try graceful shutdown first
            self._terminate_process_group(process)

            # Wait for process to die
            start_time = time.time()
            while time.time() - start_time < timeout:
                if not process.is_running():
                    self.log_info(f"Process {deployment.process_id} stopped gracefully")
                    self.state_manager.update_deployment_status(
                        deployment_id, ServiceState.STOPPED
                    )
                    return True
                time.sleep(0.1)

            # Timeout exceeded, force kill
            self.log_warning(
                f"Graceful shutdown timeout, force killing {deployment.process_id}"
            )
            self._kill_process_group(process)
            self.state_manager.update_deployment_status(
                deployment_id, ServiceState.STOPPED
            )
            return True

        except psutil.NoSuchProcess:
            # Process died during shutdown
            self.state_manager.update_deployment_status(
                deployment_id, ServiceState.STOPPED
            )
            return True

        except Exception as e:
            self.log_error(f"Error stopping process: {e}")
            return False

    def restart(self, deployment_id: str, timeout: int = 10) -> DeploymentState:
        """
        Restart a process (stop then start with same config).

        Args:
            deployment_id: Unique deployment identifier
            timeout: Seconds to wait for graceful shutdown

        Returns:
            New DeploymentState after restart

        Raises:
            ValueError: If deployment_id not found
            ProcessSpawnError: If restart fails
        """
        # Get existing deployment config
        deployment = self.state_manager.get_deployment(deployment_id)
        if not deployment:
            raise ValueError(f"Deployment not found: {deployment_id}")

        # Stop the process
        self.stop(deployment_id, timeout=timeout)

        # Create new start config from existing deployment
        config = StartConfig(
            command=deployment.command,
            working_directory=deployment.working_directory,
            environment=deployment.environment,
            port=deployment.port,
            auto_find_port=True,
            metadata=deployment.metadata,
        )

        # Remove old deployment from state
        self.state_manager.remove_deployment(deployment_id)

        # Start new process
        return self.start(config)

    def get_status(self, deployment_id: str) -> ProcessInfo | None:
        """
        Get current status and runtime information for a process.

        Args:
            deployment_id: Unique deployment identifier

        Returns:
            ProcessInfo with current status, or None if not found
        """
        deployment = self.state_manager.get_deployment(deployment_id)
        if not deployment:
            return None

        try:
            process = psutil.Process(deployment.process_id)

            # Calculate uptime
            create_time = process.create_time()
            uptime = time.time() - create_time

            # Get memory usage
            memory_info = process.memory_info()
            memory_mb = memory_info.rss / (1024 * 1024)

            # Get CPU usage
            cpu_percent = process.cpu_percent(interval=0.1)

            # Determine status
            if process.is_running():
                status = ServiceState.RUNNING
            else:
                status = ServiceState.STOPPED

            return ProcessInfo(
                deployment_id=deployment_id,
                process_id=deployment.process_id,
                status=status,
                port=deployment.port,
                uptime_seconds=uptime,
                memory_mb=memory_mb,
                cpu_percent=cpu_percent,
                is_responding=True,  # TODO: Add actual health check
            )

        except psutil.NoSuchProcess:
            return ProcessInfo(
                deployment_id=deployment_id,
                process_id=deployment.process_id,
                status=ServiceState.ERROR,  # CRASHED semantically maps to ERROR state
                port=deployment.port,
                error_message="Process no longer exists",
            )

    def list_processes(
        self, status_filter: ServiceState | None = None
    ) -> list[ProcessInfo]:
        """
        List all managed processes.

        Args:
            status_filter: Optional status to filter by

        Returns:
            List of ProcessInfo for all matching processes
        """
        if status_filter:
            deployments = self.state_manager.get_deployments_by_status(status_filter)
        else:
            deployments = self.state_manager.get_all_deployments()

        process_infos = []
        for deployment in deployments:
            info = self.get_status(deployment.deployment_id)
            if info:
                process_infos.append(info)

        return process_infos

    def is_port_available(self, port: int) -> bool:
        """
        Check if a port is available for use.

        WHY: Port conflict prevention is critical for reliable deployments.

        Args:
            port: Port number to check

        Returns:
            True if port is available
        """
        # Check if port is protected
        if is_port_protected(port):
            return False

        # Check if port is in use
        connections = psutil.net_connections()
        return all(conn.laddr.port != port for conn in connections)

    def find_available_port(
        self, preferred_port: int, max_attempts: int = 10
    ) -> int | None:
        """
        Find an available port starting from preferred_port.

        WHY: Uses linear probing to find alternative ports when preferred
        port is unavailable. Respects protected port ranges.

        Args:
            preferred_port: Starting port number
            max_attempts: Maximum number of ports to try

        Returns:
            Available port number, or None if none found
        """
        for offset in range(max_attempts):
            candidate_port = preferred_port + offset

            # Skip ports outside valid range
            if candidate_port > 65535:
                break

            # Check if port is available
            if self.is_port_available(candidate_port):
                if offset > 0:
                    self.log_info(
                        f"Port {preferred_port} unavailable, using {candidate_port}"
                    )
                return candidate_port

        return None

    def cleanup_orphans(self) -> int:
        """
        Clean up orphaned process state entries.

        WHY: Processes may crash or be killed externally, leaving stale state.

        Returns:
            Number of orphaned entries cleaned up
        """
        return self.state_manager.cleanup_dead_pids()

    def generate_deployment_id(self, project_name: str, port: int | None = None) -> str:
        """
        Generate a unique deployment ID.

        WHY: Provides consistent ID generation with optional port suffix.

        Args:
            project_name: Name of the project
            port: Optional port number to include in ID

        Returns:
            Unique deployment identifier
        """
        # Use timestamp for uniqueness
        timestamp = datetime.now(tz=UTC).strftime("%Y%m%d-%H%M%S")

        # Generate short hash from project name for readability
        name_hash = sha256(project_name.encode()).hexdigest()[:8]

        if port:
            return f"{project_name}_{name_hash}_{timestamp}_p{port}"
        return f"{project_name}_{name_hash}_{timestamp}"

    def _allocate_port(self, preferred_port: int, auto_find: bool) -> int:
        """
        Allocate a port for the deployment.

        Args:
            preferred_port: Preferred port number
            auto_find: If True, find alternative if preferred unavailable

        Returns:
            Allocated port number

        Raises:
            PortConflictError: If port unavailable and auto_find is False
        """
        # Check if preferred port is available
        if self.is_port_available(preferred_port):
            return preferred_port

        # If auto_find disabled, raise error
        if not auto_find:
            raise PortConflictError(
                f"Port {preferred_port} is unavailable and auto_find_port is disabled"
            )

        # Find alternative port
        alternative = self.find_available_port(preferred_port)
        if alternative is None:
            raise PortConflictError(
                f"No available ports found starting from {preferred_port}"
            )

        return alternative

    def _terminate_process_group(self, process: psutil.Process) -> None:
        """
        Send SIGTERM to process group for graceful shutdown.

        Args:
            process: Process to terminate
        """
        if self.is_windows:
            # Windows: terminate the process tree
            try:
                parent = process
                children = parent.children(recursive=True)
                for child in children:
                    child.terminate()
                parent.terminate()
            except (psutil.NoSuchProcess, psutil.AccessDenied):
                pass
        else:
            # Unix: send SIGTERM to process group
            try:
                os.killpg(os.getpgid(process.pid), signal.SIGTERM)
            except (ProcessLookupError, PermissionError):
                # Fallback to single process
                process.terminate()

    def _kill_process_group(self, process: psutil.Process) -> None:
        """
        Send SIGKILL to process group for force termination.

        Args:
            process: Process to kill
        """
        if self.is_windows:
            # Windows: kill the process tree
            try:
                parent = process
                children = parent.children(recursive=True)
                for child in children:
                    child.kill()
                parent.kill()
            except (psutil.NoSuchProcess, psutil.AccessDenied):
                pass
        else:
            # Unix: send SIGKILL to process group
            try:
                os.killpg(os.getpgid(process.pid), signal.SIGKILL)
            except (ProcessLookupError, PermissionError):
                # Fallback to single process
                process.kill()


__all__ = ["LocalProcessManager", "PortConflictError", "ProcessSpawnError"]
