import asyncio
import sys
import shutil
from typing import Callable, Awaitable, Optional, Dict, List
from openspace.utils.logging import Logger

logger = Logger.get_logger(__name__)

PromptFunc = Callable[[str], Awaitable[bool]]

# Global lock to prevent concurrent user prompts
_prompt_lock = asyncio.Lock()


class MCPDependencyError(RuntimeError):
    """Base exception for MCP dependency errors."""
    pass


class MCPCommandNotFoundError(MCPDependencyError):
    """Raised when a required command is not available."""
    pass


class MCPInstallationCancelledError(MCPDependencyError):
    """Raised when user cancels installation."""
    pass


class MCPInstallationFailedError(MCPDependencyError):
    """Raised when installation fails."""
    pass


class Colors:
    RESET = "\033[0m"
    BOLD = "\033[1m"
    RED = "\033[91m"
    YELLOW = "\033[93m"
    GREEN = "\033[92m"
    CYAN = "\033[96m"
    GRAY = "\033[90m"
    WHITE = "\033[97m"
    BLUE = "\033[94m"


class MCPInstallerManager:
    """
    MCP dependencies package installer manager.
    
    Responsible for detecting if the MCP server dependencies are installed, and if not, asking the user whether to install them.
    """
    
    def __init__(self, prompt: PromptFunc | None = None, auto_install: bool = False, verbose: bool = False):
        """Initialize the installer manager.
        
        Args:
            prompt: Custom user prompt function, if None, the default CLI prompt is used
            auto_install: If True, automatically install dependencies without asking the user
            verbose: If True, show detailed installation logs; if False, only show progress indicator
        """
        self._prompt: PromptFunc | None = prompt or self._default_cli_prompt
        self._auto_install = auto_install
        self._verbose = verbose
        self._installed_cache: Dict[str, bool] = {}  # Cache for checked packages
        self._failed_installations: Dict[str, str] = {}  # Track failed installations to avoid retry
        
    async def _default_cli_prompt(self, message: str) -> bool:
        """Default CLI prompt function (called within lock by ensure_dependencies)."""
        from openspace.utils.display import print_separator, colorize
        
        print()
        print_separator(70, 'c', 2)
        print(f"  {colorize('MCP dependencies installation prompt', color=Colors.BLUE, bold=True)}")
        print_separator(70, 'c', 2)
        print(f"  {message}")
        print_separator(70, 'gr', 2)
        print(f"  {colorize('[y/yes]', color=Colors.GREEN)} Install  |  {colorize('[n/no]', color=Colors.RED)} Cancel")
        print_separator(70, 'gr', 2)
        print(f"  {colorize('Your choice:', bold=True)} ", end="", flush=True)
        
        answer = await asyncio.get_running_loop().run_in_executor(None, sys.stdin.readline)
        response = answer.strip().lower() in {"y", "yes"}
        
        if response:
            print(f"{Colors.GREEN}✓ Installation confirmed{Colors.RESET}\n")
        else:
            print(f"{Colors.RED}✗ Installation cancelled{Colors.RESET}\n")
        
        return response
    
    async def _ask_user(self, message: str) -> bool:
        """Ask the user whether to install."""
        if self._auto_install:
            logger.info("Automatic installation mode enabled, will automatically install dependencies")
            return True
            
        if self._prompt:
            try:
                return await self._prompt(message)
            except Exception as e:
                logger.error(f"Error asking user: {e}")
                return False
        return False
    
    def _check_command_available(self, command: str) -> bool:
        """Check if the command is available.
        
        Args:
            command: The command to check (e.g. "npx", "uvx")
            
        Returns:
            bool: Whether the command is available
        """
        return shutil.which(command) is not None
    
    async def _check_package_installed(self, command: str, args: List[str]) -> bool:
        """Check if the package is installed.
        
        Args:
            command: The command to check (e.g. "npx", "uvx")
            args: The arguments list
            
        Returns:
            bool: Whether the package is installed
        """
        # Build cache key
        cache_key = f"{command}:{':'.join(args)}"
        
        # Check cache
        if cache_key in self._installed_cache:
            return self._installed_cache[cache_key]
        
        # For different types of commands, use different check methods
        try:
            if command == "npx":
                # For npx, check if the npm package exists
                package_name = self._extract_npm_package(args)
                if package_name:
                    result = await self._check_npm_package(package_name)
                    self._installed_cache[cache_key] = result
                    return result
            elif command == "uvx":
                # For uvx, check if the Python package exists
                package_name = self._extract_python_package(args)
                if package_name:
                    result = await self._check_python_package(package_name)
                    self._installed_cache[cache_key] = result
                    return result
            elif command == "uv":
                # For "uv run --with package ...", check if the Python package exists
                package_name = self._extract_uv_package(args)
                if package_name:
                    result = await self._check_uv_pip_package(package_name)
                    self._installed_cache[cache_key] = result
                    return result
        except Exception as e:
            logger.debug(f"Error checking package installation status: {e}")
        
        # Default to assuming not installed
        return False
    
    def _extract_npm_package(self, args: List[str]) -> Optional[str]:
        """Extract package name from npx arguments.
        
        Args:
            args: npx arguments list, e.g. ["-y", "mcp-excalidraw-server"] or ["bazi-mcp"]
            
        Returns:
            Package name (without version tag) or None
        """
        for i, arg in enumerate(args):
            # Skip option parameters
            if arg.startswith("-"):
                continue
            
            # Found package name, now strip version tag
            package_name = arg
            
            # Handle scoped packages: @scope/package@version -> @scope/package
            if package_name.startswith("@"):
                # Scoped package like @rtuin/mcp-mermaid-validator@latest
                parts = package_name.split("/", 1)
                if len(parts) == 2:
                    scope = parts[0]
                    name_with_version = parts[1]
                    # Remove version tag from name part (e.g., "pkg@latest" -> "pkg")
                    name = name_with_version.split("@")[0] if "@" in name_with_version else name_with_version
                    return f"{scope}/{name}"
                return package_name
            else:
                # Regular package like mcp-deepwiki@latest -> mcp-deepwiki
                return package_name.split("@")[0] if "@" in package_name else package_name
        
        return None
    
    def _extract_python_package(self, args: List[str]) -> Optional[str]:
        """Extract package name from uvx arguments.
        
        Args:
            args: uvx arguments list, e.g. ["--from", "office-powerpoint-mcp-server", "ppt_mcp_server"]
                  or ["--with", "mcp==1.9.0", "sitemap-mcp-server"]
                  or ["arxiv-mcp-server", "--storage-path", "./path"]
            
        Returns:
            Package name or None
        """
        # Find --from parameter (this is the package to install)
        for i, arg in enumerate(args):
            if arg == "--from" and i + 1 < len(args):
                return args[i + 1]
        
        # Skip option flags and their values, find the main package (FIRST positional arg)
        # Options that take a value: --with, --python, --from, --storage-path, etc.
        options_with_value = {"--with", "--from", "--python", "-p", "--storage-path"}
        skip_next = False
        
        for arg in args:
            if skip_next:
                skip_next = False
                continue
            if arg in options_with_value:
                skip_next = True
                continue
            if arg.startswith("-"):
                # Other flags without values (or unknown options with values)
                # Also skip the next arg if it looks like an option value (doesn't start with -)
                continue
            # First non-option argument is the package name
            return arg
        
        return None
    
    def _extract_uv_package(self, args: List[str]) -> Optional[str]:
        """Extract package name from uv run arguments.
        
        Args:
            args: uv arguments list, e.g. ["run", "--with", "biomcp-python", "biomcp", "run"]
            
        Returns:
            Package name or None
        """
        # Find --with parameter (this specifies the package to install)
        for i, arg in enumerate(args):
            if arg == "--with" and i + 1 < len(args):
                package_name = args[i + 1]
                # Remove version specifier if present (e.g., "mcp==1.9.0" -> "mcp")
                if "==" in package_name:
                    return package_name.split("==")[0]
                if ">=" in package_name:
                    return package_name.split(">=")[0]
                return package_name
        
        return None
    
    async def _check_npm_package(self, package_name: str) -> bool:
        """Check if the npm package is globally installed.
        
        Args:
            package_name: npm package name
            
        Returns:
            bool: Whether the npm package is installed
        """
        try:
            process = await asyncio.create_subprocess_exec(
                "npm", "list", "-g", package_name,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            stdout, stderr = await process.communicate()
            
            # npm list returns 0 if the package is installed
            return process.returncode == 0
        except Exception as e:
            logger.debug(f"Error checking npm package {package_name}: {e}")
            return False
    
    async def _check_python_package(self, package_name: str) -> bool:
        """Check if the Python package is installed as a uvx tool.
        
        uvx tools are installed in ~/.local/share/uv/tools/ directory,
        not in the current pip environment.
        
        Args:
            package_name: Python package/tool name
            
        Returns:
            bool: Whether the uvx tool is installed
        """
        from pathlib import Path
        
        # Strip version specifier if present (e.g., "mcp==1.9.0" -> "mcp")
        clean_name = package_name.split("==")[0].split(">=")[0].split("<=")[0].split(">")[0].split("<")[0]
        
        # Check if uvx tool exists in the standard uv tools directory
        uv_tools_dir = Path.home() / ".local" / "share" / "uv" / "tools"
        tool_dir = uv_tools_dir / clean_name
        
        if tool_dir.exists():
            logger.debug(f"uvx tool '{clean_name}' found at {tool_dir}")
            return True
        
        # Fallback: try running uvx with --help to check if it's available
        try:
            process = await asyncio.create_subprocess_exec(
                "uvx", clean_name, "--help",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            # Just wait briefly, don't need the full output
            try:
                await asyncio.wait_for(process.communicate(), timeout=5.0)
            except asyncio.TimeoutError:
                process.kill()
                await process.wait()
            
            # If it didn't error immediately, the tool likely exists
            return process.returncode == 0
        except Exception as e:
            logger.debug(f"Error checking uvx tool {clean_name}: {e}")
        
        return False
    
    async def _check_uv_pip_package(self, package_name: str) -> bool:
        """Check if a Python package is installed via uv pip.
        
        Args:
            package_name: Python package name
            
        Returns:
            bool: Whether the package is installed
        """
        # Strip version specifier if present
        clean_name = package_name.split("==")[0].split(">=")[0].split("<=")[0].split(">")[0].split("<")[0]
        
        try:
            # Try using uv pip show to check if package is installed
            process = await asyncio.create_subprocess_exec(
                "uv", "pip", "show", clean_name,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            stdout, stderr = await process.communicate()
            
            if process.returncode == 0:
                logger.debug(f"uv pip package '{clean_name}' found")
                return True
        except Exception as e:
            logger.debug(f"Error checking uv pip package {clean_name}: {e}")
        
        # Fallback: check with regular pip
        try:
            process = await asyncio.create_subprocess_exec(
                "pip", "show", clean_name,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            stdout, stderr = await process.communicate()
            
            return process.returncode == 0
        except Exception as e:
            logger.debug(f"Error checking pip package {clean_name}: {e}")
        
        return False
    
    async def _install_package(self, command: str, args: List[str], use_sudo: bool = False) -> bool:
        """Execute the install command.
        
        Args:
            command: The command to execute (e.g. "npx", "uvx")
            args: The arguments list
            use_sudo: Whether to use sudo for installation
            
        Returns:
            bool: Whether the installation is successful
        """
        install_command = self._get_install_command(command, args)
        
        if not install_command:
            logger.error("Cannot determine install command")
            return False
        
        # Add sudo if requested
        if use_sudo:
            install_command = ["sudo"] + install_command
        
        logger.info(f"Executing install command: {' '.join(install_command)}")
        
        try:
            # For sudo commands, always show verbose output so password prompt is visible
            if self._verbose or use_sudo:
                # Verbose mode: show all installation logs
                from openspace.utils.display import print_separator, colorize
                
                print_separator(70, 'c', 2)
                if use_sudo:
                    print(f"  {colorize('Installing with administrator privileges...', color=Colors.BLUE)}")
                    print(f"  {colorize('>> You will be prompted for your password below <<', color=Colors.YELLOW)}")
                else:
                    print(f"  {colorize('Installing dependencies...', color=Colors.BLUE)}")
                print(f"  {colorize('Command: ' + ' '.join(install_command), color=Colors.GRAY)}")
                print_separator(70, 'c', 2)
                print()
                
                # For sudo, don't redirect stdin so password prompt works
                if use_sudo:
                    process = await asyncio.create_subprocess_exec(
                        *install_command,
                        stdout=asyncio.subprocess.PIPE,
                        stderr=asyncio.subprocess.STDOUT,
                        stdin=None  # Let sudo use terminal for password
                    )
                else:
                    process = await asyncio.create_subprocess_exec(
                        *install_command,
                        stdout=asyncio.subprocess.PIPE,
                        stderr=asyncio.subprocess.STDOUT
                    )
                
                # Real-time output of installation logs
                output_lines = []
                while True:
                    line = await process.stdout.readline()
                    if not line:
                        break
                    line_str = line.decode().rstrip()
                    output_lines.append(line_str)
                    print(f"{Colors.GRAY}{line_str}{Colors.RESET}")
                
                await process.wait()
                full_output = '\n'.join(output_lines)
            else:
                # Quiet mode: only show progress indicator
                print(f"\n{Colors.BLUE}Installing dependencies...{Colors.RESET} ", end="", flush=True)
                
                process = await asyncio.create_subprocess_exec(
                    *install_command,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE
                )
                
                # Show spinner animation while installing
                spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
                spinner_idx = 0
                
                while True:
                    try:
                        await asyncio.wait_for(process.wait(), timeout=0.1)
                        break
                    except asyncio.TimeoutError:
                        print(f"\r{Colors.BLUE}Installing dependencies...{Colors.RESET} {Colors.CYAN}{spinner[spinner_idx]}{Colors.RESET}", end="", flush=True)
                        spinner_idx = (spinner_idx + 1) % len(spinner)
                
                # Clear the spinner line
                print(f"\r{' ' * 100}\r", end="", flush=True)
                
                # Collect output
                stdout, stderr = await process.communicate()
                full_output = (stdout or stderr).decode() if (stdout or stderr) else ""
            
            if process.returncode == 0:
                print(f"{Colors.GREEN}✓ Dependencies installed successfully{Colors.RESET}")
                if not use_sudo:
                    print(f"{Colors.GRAY}(Note: First connection may take a moment to initialize){Colors.RESET}")
                # Update cache
                cache_key = f"{command}:{':'.join(args)}"
                self._installed_cache[cache_key] = True
                return True
            else:
                # Check if it's a permission error
                is_permission_error = "EACCES" in full_output or "permission denied" in full_output.lower()
                
                if is_permission_error and not use_sudo:
                    print(f"\n{Colors.YELLOW}Permission denied{Colors.RESET}")
                    print(f"{Colors.GRAY}The installation requires administrator privileges.{Colors.RESET}\n")
                    
                    # Ask user if they want to use sudo
                    message = (
                        f"\n{Colors.WHITE}Administrator privileges required{Colors.RESET}\n\n"
                        f"Command: {Colors.GRAY}{' '.join(install_command)}{Colors.RESET}\n\n"
                        f"{Colors.YELLOW}Do you want to retry with sudo (requires password)?{Colors.RESET}"
                    )
                    
                    if await self._ask_user(message):
                        # No extra print needed, the verbose mode will show clear instructions
                        return await self._install_package(command, args, use_sudo=True)
                    else:
                        print(f"\n{Colors.RED}✗ Installation cancelled{Colors.RESET}")
                        return False
                else:
                    print(f"{Colors.RED}✗ Dependencies installation failed (return code: {process.returncode}){Colors.RESET}")
                    # Show error output if not already shown
                    if not self._verbose and full_output:
                        # Limit error output to last 20 lines
                        error_lines = full_output.split('\n')
                        if len(error_lines) > 20:
                            error_lines = ['...(truncated)...'] + error_lines[-20:]
                        print(f"{Colors.GRAY}Error output:\n{chr(10).join(error_lines)}{Colors.RESET}")
                    
                    # Add general guidance for manual installation
                    print(f"\n{Colors.YELLOW}Tip:{Colors.RESET} {Colors.GRAY}If automatic installation fails, please refer to the")
                    print(f"official documentation of the MCP server for manual installation instructions.{Colors.RESET}\n")
                    
                    return False
                
        except Exception as e:
            logger.error(f"Error installing dependencies: {e}")
            print(f"{Colors.RED}✗ Error occurred during installation: {e}{Colors.RESET}")
            return False
    
    def _get_install_command(self, command: str, args: List[str]) -> Optional[List[str]]:
        """Generate install command based on command type.
        
        Args:
            command: The command to execute (e.g. "npx", "uvx", "uv")
            args: The original arguments list
            
        Returns:
            Install command list or None
        """
        if command == "npx":
            package_name = self._extract_npm_package(args)
            if package_name:
                return ["npm", "install", "-g", package_name]
        elif command == "uvx":
            package_name = self._extract_python_package(args)
            if package_name:
                return ["pip", "install", package_name]
        elif command == "uv":
            # Handle "uv run --with package_name ..." format
            package_name = self._extract_uv_package(args)
            if package_name:
                return ["uv", "pip", "install", package_name]
        
        return None
    
    async def ensure_dependencies(
        self, 
        server_name: str,
        command: str, 
        args: List[str]
    ) -> bool:
        """Ensure the dependencies of the MCP server are installed.
        
        This method checks if the dependencies are installed, and if not, asks the user whether to install them.
        
        Args:
            server_name: MCP server name (for display purposes)
            command: The command to execute (e.g. "npx", "uvx")
            args: The arguments list
            
        Returns:
            bool: Whether the dependencies are installed (installed or successfully installed)
            
        Raises:
            RuntimeError: When the command is not available or the user refuses to install
        """
        # Use lock to ensure entire installation process is atomic
        async with _prompt_lock:
            return await self._ensure_dependencies_impl(server_name, command, args)
    
    async def _ensure_dependencies_impl(
        self, 
        server_name: str,
        command: str, 
        args: List[str]
    ) -> bool:
        """Internal implementation of ensure_dependencies (called within lock)."""
        # Skip dependency checking for direct script execution commands
        # These commands run scripts directly and don't need package installation
        SKIP_COMMANDS = {"node", "python", "python3", "bash", "sh", "deno", "bun"}
        
        if command.lower() in SKIP_COMMANDS:
            logger.debug(f"Skipping dependency check for direct script execution command: {command}")
            return True
        
        # Skip dependency checking for GitHub-based npx packages
        # These packages are handled directly by npx which downloads, builds, and runs them
        # npm install -g doesn't work properly for GitHub packages that require building
        if command == "npx":
            package_name = self._extract_npm_package(args)
            if package_name and package_name.startswith("github:"):
                logger.debug(f"Skipping dependency check for GitHub-based npx package: {package_name}")
                return True
        
        # Check if this server has already failed installation
        cache_key = f"{server_name}:{command}:{':'.join(args)}"
        if cache_key in self._failed_installations:
            error_msg = self._failed_installations[cache_key]
            logger.debug(f"Skipping installation for '{server_name}' - previously failed")
            raise MCPDependencyError(error_msg)
        
        # Special handling for uvx - check if uv is installed
        if command == "uvx":
            if not self._check_command_available("uv"):
                # Only show once to user, no verbose logging
                print(f"\n{Colors.RED}✗ Server '{server_name}' requires 'uv' to be installed{Colors.RESET}")
                print(f"{Colors.YELLOW}Please install uv first:")
                print(f"  • macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh")
                print(f"  • Or with pip: pip install uv")
                print(f"  • Or with brew: brew install uv{Colors.RESET}\n")
                
                error_msg = f"uvx requires 'uv' to be installed (server: {server_name})"
                self._failed_installations[cache_key] = error_msg
                raise MCPCommandNotFoundError(error_msg)
        
        # Check if the command is available
        if not self._check_command_available(command):
            error_msg = (
                f"Command '{command}' is not available.\n"
                f"Please install the necessary tools first."
            )
            logger.error(error_msg)
            self._failed_installations[cache_key] = error_msg
            raise MCPCommandNotFoundError(error_msg)
        
        # Check if the package is installed
        if await self._check_package_installed(command, args):
            logger.debug(f"The dependencies of the MCP server '{server_name}' are installed")
            return True
        
        # Extract package name for display
        if command == "npx":
            package_name = self._extract_npm_package(args)
            package_type = "npm"
        elif command == "uvx":
            package_name = self._extract_python_package(args)
            package_type = "Python"
        elif command == "uv":
            package_name = self._extract_uv_package(args)
            package_type = "Python"
        else:
            package_name = f"{command} {' '.join(args)}"
            package_type = "package"
        
        # Build the message for displaying the install command
        install_cmd = self._get_install_command(command, args)
        
        # If we can't determine an install command, show helpful message
        if not install_cmd:
            print(f"\n{Colors.YELLOW}Cannot automatically install dependencies for '{server_name}'{Colors.RESET}")
            print(f"{Colors.GRAY}Command: {command} {' '.join(args)}{Colors.RESET}")
            print(f"\n{Colors.WHITE}This MCP server may require manual installation or configuration.{Colors.RESET}")
            print(f"{Colors.GRAY}Please refer to the MCP server's official documentation for installation instructions.{Colors.RESET}\n")
            
            error_msg = f"Manual installation required for '{server_name}' (command: {command})"
            self._failed_installations[cache_key] = error_msg
            raise MCPDependencyError(error_msg)
        
        install_cmd_str = ' '.join(install_cmd)
        
        # Build the message
        message = (
            f"\n{Colors.WHITE}The MCP server needs to install dependencies{Colors.RESET}\n\n"
            f"Server name: {Colors.CYAN}{server_name}{Colors.RESET}\n"
            f"Package type: {Colors.YELLOW}{package_type}{Colors.RESET}\n"
            f"Package name: {Colors.YELLOW}{package_name or 'Unknown'}{Colors.RESET}\n"
            f"Install command: {Colors.GRAY}{install_cmd_str}{Colors.RESET}\n\n"
            f"{Colors.YELLOW}Whether to install this dependency package?{Colors.RESET}"
        )
        
        # Ask the user
        if not await self._ask_user(message):
            error_msg = f"User cancelled the dependency installation for '{server_name}'"
            logger.warning(error_msg)
            self._failed_installations[cache_key] = error_msg
            raise MCPInstallationCancelledError(error_msg)
        
        # Execute installation
        success = await self._install_package(command, args)
        
        if not success:
            error_msg = f"Dependency installation failed for '{server_name}'"
            logger.error(error_msg)
            self._failed_installations[cache_key] = error_msg
            raise MCPInstallationFailedError(error_msg)
        
        return True


# Global singleton instance
_global_installer: Optional[MCPInstallerManager] = None


def get_global_installer() -> MCPInstallerManager:
    """Get the global installer manager instance."""
    global _global_installer
    if _global_installer is None:
        _global_installer = MCPInstallerManager()
    return _global_installer

def set_global_installer(installer: MCPInstallerManager) -> None:
    """Set the global installer manager instance."""
    global _global_installer
    _global_installer = installer