import signal
import os
import time
import json
import threading
import fcntl
from pathlib import Path
from enum import Enum
from types import FunctionType
from typing import Any, Tuple, Union, List, Optional, Dict, TYPE_CHECKING, AsyncIterator
from blocks.utils import BackgroundCommandOutput
import uuid
from blocks_control_sdk.utils import patch_blocks_runtime_config, BlocksRuntimeConfigKeys
from blocks_control_sdk.logger import log
from pydantic import BaseModel
from blocks_control_sdk.tools.blocks import send_message__blocks
from blocks_control_sdk.utils import get_blocks_runtime_config, patch_blocks_runtime_config, BlocksRuntimeConfigKeys

if TYPE_CHECKING:
    from litellm.types.utils import Message

class NotificationsV2:
    NOTIFY_COMPLETE_V2 = "notify_complete_v2"
    NOTIFY_MESSAGE_V2 = "notify_message_v2"
    NOTIFY_TOOL_CALL_V2 = "notify_tool_call_v2"
    NOTIFY_START_V2 = "notify_start_v2"
    NOTIFY_RESUME_V2 = "notify_resume_v2"
    NOTIFY_INTERNAL_MESSAGE = "notify_internal_message"
    NOTIFY_INTERRUPT_V2 = "notify_interrupt_v2"

class NotifyBase(BaseModel):
    type: str
    status: Optional[str] = None
    chat_thread_id: Optional[str] = None

class NotifyMessageArgs(NotifyBase):
    type: str = NotificationsV2.NOTIFY_MESSAGE_V2
    message: Any  # Accepts both local Message and litellm Message

class NotifyCompleteArgs(NotifyBase):
    type: str = NotificationsV2.NOTIFY_COMPLETE_V2
    last_message: str

class NotifyToolCallArgs(NotifyBase):
    type: str = NotificationsV2.NOTIFY_TOOL_CALL_V2
    tool_name: str
    serialized_args: str

class NotifyStartArgs(NotifyBase):
    type: str = NotificationsV2.NOTIFY_START_V2

class NotifyResumeArgs(NotifyBase):
    type: str = NotificationsV2.NOTIFY_RESUME_V2

class NotifyInternalMessageArgs(NotifyBase):
    type: str = NotificationsV2.NOTIFY_INTERNAL_MESSAGE
    payload: dict  # The parsed JSON message
    src: Optional[str] = None  # Source identifier (tool name, etc.)

class NotifyInterruptArgs(NotifyBase):
    type: str = NotificationsV2.NOTIFY_INTERRUPT_V2

class LLM(Enum):
    CLAUDE = "claude"
    CODEX = "codex"
    GEMINI = "gemini"
    CURSOR = "cursor"
    OPENCODE = "opencode"
    KIMI = "kimi"

LLM_API_KEYS = {
    LLM.CLAUDE: os.getenv("ANTHROPIC_API_KEY"),
    LLM.CODEX: os.getenv("OPENAI_API_KEY"),
    LLM.GEMINI: os.getenv("GEMINI_API_KEY"),
    LLM.CURSOR: os.getenv("CURSOR_API_KEY"),
    LLM.OPENCODE: os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY"),
    LLM.KIMI: os.getenv("KIMI_API_KEY"),
}

class CodingAgentBaseCLI():

    class AgentStatus:
        TURNS_COMPLETED = "turns_completed"
        TURNS_IN_PROGRESS = "turns_in_progress"
        TURNS_NOT_STARTED = "turns_not_started"

    class Notifications:
        NOTIFY_START_V2 = "notify_start_v2"

        NOTIFY_RESUME_V2 = "notify_resume_v2"

        NOTIFY_COMPLETE_V2 = "notify_complete_v2"

        NOTIFY_MESSAGE_V2 = "notify_message_v2"

        NOTIFY_TODO_LIST_UPDATE = "notify_todo_list_update"

        NOTIFY_TOOL_CALL_V2 = "notify_tool_call_v2"

        NOTIFY_INTERNAL_MESSAGE = "notify_internal_message"

        NOTIFY_INTERRUPT_V2 = "notify_interrupt_v2"

    pid: int = None
    child_pids: List[int] = []  # Track child process PIDs from tool subprocesses
    is_session_active: bool = False
    status: str = AgentStatus.TURNS_NOT_STARTED
    is_interrupt_triggered: bool = False
    is_plan_mode_active: bool = False
    chat_thread_id: str = None

    def __init__(self, chat_thread_id: str = None):
        self.agent_id = str(uuid.uuid4())
        self.notifications = self.Notifications()
        self._notification_callbacks = {}
        self.assistant_messages = []  # Initialize as instance variable, not class variable
        self.callback_errors = []  # Track any errors that occur in notification callbacks
        self._internal_messaging_obj = None
        self._internal_messaging_polling_thread = None
        self._internal_messaging_stop_polling = threading.Event()
        self._mcp_servers = {}
        self._background_command_output: BackgroundCommandOutput = None
        self.set_chat_thread_id(chat_thread_id)

        # Write agent_id to runtime config so tools can read it
        patch_blocks_runtime_config({
            BlocksRuntimeConfigKeys.AGENT_ID: self.agent_id
        })

        # Start internal messaging polling if enabled
        from blocks_control_sdk.control.internal_messaging import ENABLE_INTERNAL_MESSAGING
        if ENABLE_INTERNAL_MESSAGING:
            self._start_message_polling()

    
    def enter_plan_mode(self):
        self.is_plan_mode_active = True

    def exit_plan_mode(self):
        self.is_plan_mode_active = False

    def tool_call_arg_kb_size(self, content: str) -> int:
        content = content or ""
        size_bytes = len(content.encode('utf-8'))
        size_kb = size_bytes / 1024
        return int(size_kb)

    def tool_call_arg_kb_size_truncate_to_limit(self, content: str, kb_limit: int) -> str:
        limit_bytes = kb_limit * 1024
        encoded = content.encode("utf-8")
        if len(encoded) <= limit_bytes:
            return content
        # cut the bytes safely
        truncated_bytes = encoded[:limit_bytes]
        # decode with ignore to drop partial utf-8 chars
        truncated_str = truncated_bytes.decode("utf-8", errors="ignore")
        return truncated_str + "..."

    @staticmethod
    def resolve_agent_class(provider: LLM):
        from .agent_codex import CodexAgentCLI
        from .agent_claude_exp import ClaudeCodeCLIExp
        from .agent_gemini_exp import GeminiAgentCLIExp
        from .agent_cursor import CursorAgentCLI
        from .agent_opencode import OpenCodeAgentCLI
        from .agent_kimi import KimiAgentCLI

        if provider == LLM.CODEX:
            return CodexAgentCLI
        elif provider == LLM.CLAUDE:
            return ClaudeCodeCLIExp
        elif provider == LLM.GEMINI:
            return GeminiAgentCLIExp
        elif provider == LLM.CURSOR:
            return CursorAgentCLI
        elif provider == LLM.OPENCODE:
            return OpenCodeAgentCLI
        elif provider == LLM.KIMI:
            return KimiAgentCLI
        else:
            raise ValueError(f"Unknown provider: {provider}")

    @staticmethod
    def resolve_provider_from_keys(default_provider: Union[LLM, None] = None) -> LLM:

        if default_provider is not None and default_provider in LLM_API_KEYS:
            log.info(f"Attempting to resolve LLM provider from default_provider in automation: {default_provider}")
            if LLM_API_KEYS.get(default_provider) is not None:
                return default_provider
            else:
                raise ValueError(
                    f"LLM API key for {default_provider} is not set, you must set the environment variable {default_provider.value}"
                )

        log.info("Attempting to resolve LLM provider from api keys...")

        if LLM_API_KEYS.get(LLM.CLAUDE) is not None:
            return LLM.CLAUDE
        elif LLM_API_KEYS.get(LLM.CODEX) is not None:
            return LLM.CODEX
        elif LLM_API_KEYS.get(LLM.GEMINI) is not None:
            return LLM.GEMINI
        elif LLM_API_KEYS.get(LLM.CURSOR) is not None:
            return LLM.CURSOR
        else:
            llm_name_strs = [llm.value for llm in LLM]
            raise ValueError(
                "No LLM API keys set, you must set at least one of the following environment variables: "
                f"{', '.join(llm_name_strs)}"
            )

    def _kill_process(self, pid: int):
        try:
            os.killpg(pid, signal.SIGTERM)
            time.sleep(1) 
            os.killpg(pid, signal.SIGKILL) 
        except ProcessLookupError:
            pass
        except PermissionError:
            # Fallback to killing just the main process
            try:
                os.kill(pid, signal.SIGKILL)
            except ProcessLookupError:
                pass

    def register_notification(self, notification_type: str, callback):
        """Register a callback for a specific notification type"""
        if notification_type not in self._notification_callbacks:
            self._notification_callbacks[notification_type] = []
        self._notification_callbacks[notification_type].append(callback)

    def unregister_notification(self, notification_type: str, callback):
        """
        Remove a previously registered callback for a notification type.

        Args:
            notification_type: The notification type (from self.Notifications)
            callback: The callback function to remove
        """
        if notification_type in self._notification_callbacks:
            try:
                self._notification_callbacks[notification_type].remove(callback)
            except ValueError:
                # Callback not in list, safe to ignore
                pass

    @staticmethod
    def merge_agents_file_into_target(source_file: str, target_file: str, repo_name: str) -> bool:
        """
        Merge source agents/CLAUDE markdown file INTO target file using @import syntax.

        Uses Claude Code's native @import syntax to reference source files.
        This is lightweight, preserves original files, and Claude Code will
        auto-load the imported content into context.

        Args:
            source_file: Path to source agents.md or CLAUDE.md from cloned repo
            target_file: Path to target file to merge into
            repo_name: Repository name for section header (required)

        Returns:
            True if merge succeeded, False otherwise
        """
        # Validate source file exists
        if not os.path.exists(source_file):
            log.warning(f"[Markdown Merge] Source file not found: {source_file}")
            return False

        # Ensure target directory exists
        target_dir = os.path.dirname(target_file)
        if target_dir and not os.path.exists(target_dir):
            os.makedirs(target_dir, exist_ok=True)

        # Ensure target file exists (create with default header if not)
        if not os.path.exists(target_file):
            with open(target_file, 'w') as f:
                f.write("# Project Guidelines\n\n")

        # Use file-based locking to prevent concurrent modifications
        lock_file_path = target_file + ".lock"
        with open(lock_file_path, 'w') as lock:
            try:
                # Try non-blocking lock first
                fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
            except BlockingIOError:
                # Another merge in progress, wait for it
                log.info("[Markdown Merge] Lock held by another process, waiting...")
                fcntl.flock(lock.fileno(), fcntl.LOCK_EX)

            try:
                # Calculate relative path from target directory to source file
                target_dir_abs = os.path.dirname(os.path.abspath(target_file))
                source_abs = os.path.abspath(source_file)
                rel_path = os.path.relpath(source_abs, target_dir_abs)

                # Build @import section using Claude Code's native import syntax
                section_header = f"\n\n## {repo_name}\n\n"
                import_directive = f"@{rel_path}\n"
                link_entry = section_header + import_directive

                # Read current target content
                with open(target_file, 'r') as f:
                    target_content = f.read()

                # Check if this link already exists (by relative path)
                if rel_path in target_content:
                    log.info(f"[Markdown Merge] Link to {repo_name} already exists, skipping")
                    return True

                # Append link to target file
                with open(target_file, 'w') as f:
                    f.write(target_content + link_entry)

                log.info(f"[Markdown Merge] Added @import for {repo_name} in {target_file}")
                return True

            except Exception as e:
                log.error(f"[Markdown Merge] Error during merge: {e}")
                return False

            finally:
                # Release lock
                fcntl.flock(lock.fileno(), fcntl.LOCK_UN)

    def notify_v2(self, arg: NotifyBase):
        """Trigger all callbacks registered for a notification type with args and kwargs"""

        notification_type = arg.type

        arg.chat_thread_id = self.chat_thread_id
        arg.status = self.status

        size = notification_type in self._notification_callbacks and len(self._notification_callbacks[notification_type]) or 0

        log.debug(f"Notifying callbacks for notification type: {notification_type}, #callbacks: {size}")

        if notification_type in self._notification_callbacks:
            for callback in self._notification_callbacks[notification_type]:
                if callable(callback):
                    try:
                        callback(arg)
                    except Exception as e:
                        error_info = {
                            "notification_type": notification_type,
                            "callback": str(callback),
                            "error": str(e)
                        }
                        self.callback_errors.append(error_info)
                        log.error(f"Error calling callback {callback} for notification type {notification_type}: {e}")
                else:
                    log.warning(f"Callback {callback} for notification type {notification_type} is not callable")

    def clear_messages(self):
        self.assistant_messages = []

    def set_chat_thread_id(self, chat_thread_id: str = None):

        if not chat_thread_id:
            _cached_chat_thread_id = get_blocks_runtime_config().get(BlocksRuntimeConfigKeys.CHAT_THREAD_ID)
            _env_chat_thread_id = os.getenv("BLOCKS_INITIAL_CHAT_THREAD_ID")
            _generated_chat_thread_id = str(uuid.uuid4())

            chat_thread_id = _cached_chat_thread_id or _env_chat_thread_id or _generated_chat_thread_id

        patch_blocks_runtime_config({
            BlocksRuntimeConfigKeys.CHAT_THREAD_ID: chat_thread_id
        })
        self.chat_thread_id = chat_thread_id

    def _start_message_polling(self):
        """Start background thread to poll for internal messages from tools"""
        from blocks_control_sdk.control.internal_messaging import InternalMessaging, POLL_INTERVAL_SECONDS

        self._internal_messaging_obj = InternalMessaging.get_instance()

        def poll_loop():
            while not self._internal_messaging_stop_polling.is_set():
                try:
                    messages = self._internal_messaging_obj.poll_messages(self.agent_id)
                    for msg in messages:
                        self.notify_v2(NotifyInternalMessageArgs(
                            payload=msg.payload,
                            src=msg.src
                        ))
                except Exception as e:
                    log.error(f"[InternalMessaging] Poll error: {e}")
                self._internal_messaging_stop_polling.wait(POLL_INTERVAL_SECONDS)

        self._internal_messaging_polling_thread = threading.Thread(target=poll_loop, daemon=True, name="internal-msg-poll")
        self._internal_messaging_polling_thread.start()

    def stop_message_polling(self):
        """Call on agent shutdown to stop internal message polling"""
        if self._internal_messaging_stop_polling:
            self._internal_messaging_stop_polling.set()
        if self._internal_messaging_obj:
            self._internal_messaging_obj.stop()

    def new_chat_thread(self, chat_thread_id: str = None, new_session: bool = False):
        self.set_chat_thread_id(chat_thread_id)
        self.clear_messages()
        if new_session:
            self.restart_session()
    
    def notify_complete_callback(self, text: str):
        """Default callback method that can be overridden"""
        pass

    def _start(self):
        self.is_session_active = True

    def interrupt(self):
        # Kill child PIDs first (from tool subprocesses)
        for child_pid in self.child_pids:
            self._kill_process(child_pid)
        self.child_pids = []

        # Then kill main agent process
        if self.pid is not None:
            self._kill_process(self.pid)
            self.pid = None
            self.is_interrupt_triggered = True

        # Wait for monitor thread to finish processing callbacks
        if self._background_command_output and self._background_command_output._monitor_thread:
            self._background_command_output._monitor_thread.join()

        self.notify_v2(NotifyInterruptArgs())

    def _set_background_command_output(self, output: BackgroundCommandOutput):
        self._background_command_output = output

    def register_child_pid(self, pid: int):
        """Register a child process PID for cleanup during interrupt."""
        if pid and pid not in self.child_pids:
            self.child_pids.append(pid)

    def hot_reload(self) -> BackgroundCommandOutput:
        """
        Hot reload: interrupt current execution and resume with "continue" prompt.
        Does NOT fire NotifyCompleteArgs. Silent operation.

        This method:
        1. Sets is_interrupt_triggered to skip the on_complete notification
        2. Kills the current process
        3. Calls agent-specific cleanup via _hot_reload_cleanup()
        4. Resumes with query("continue") using the same session

        Returns:
            BackgroundCommandOutput from the resumed query
        """
        self.is_interrupt_triggered = True

        # Kill child PIDs first (from tool subprocesses)
        for child_pid in self.child_pids:
            self._kill_process(child_pid)
        self.child_pids = []

        # Kill main agent process
        if self.pid is not None:
            self._kill_process(self.pid)
            self.pid = None

        # Wait for monitor thread to finish processing callbacks
        if self._background_command_output and self._background_command_output._monitor_thread:
            self._background_command_output._monitor_thread.join()

        # Agent-specific cleanup (template method)
        self._hot_reload_cleanup()

        # Resume with "continue" prompt
        # is_session_active remains True, so resume flags will be used
        return self.query("Continue I've finished setting up the repositories.")

    def _hot_reload_cleanup(self):
        """
        Override in subclasses for agent-specific cleanup during hot reload.

        Base implementation just resets the interrupt flag.
        Subclasses should call super() or reset is_interrupt_triggered themselves.
        """
        self.is_interrupt_triggered = False

    @staticmethod
    def execute_blocks_post_clone_script(repo_folder: str) -> bool:
        """
        Execute .blocks/post-clone script if it exists.

        Checks for:
        1. {repo_folder}/.blocks/post-clone.sh
        2. {repo_folder}/.blocks/post-clone

        Args:
            repo_folder: Path to the cloned repository

        Returns:
            True if a script was found and executed, False otherwise
        """
        from blocks import bash
        import stat

        blocks_dir = os.path.join(repo_folder, ".blocks")
        script_candidates = [
            os.path.join(blocks_dir, "post-clone.sh"),
            os.path.join(blocks_dir, "post-clone"),
        ]

        chat_thread_id = get_blocks_runtime_config().get(BlocksRuntimeConfigKeys.CHAT_THREAD_ID)

        def send_assistant_message(message: str):
            send_message__blocks(
                message=message,
                urgency_level_between_zero_to_10=10,
                role="assistant",
                type="message",
                chat_thread_id=chat_thread_id
            )

        for script_path in script_candidates:
            if os.path.exists(script_path) and os.path.isfile(script_path):
                log.info(f"[Post-Clone Script] Found: {script_path}")

                # Ensure script is executable
                current_mode = os.stat(script_path).st_mode
                os.chmod(script_path, current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)

                # Execute with repo folder as working directory
                command = f"cd {repo_folder} && {script_path}"
                log.info(f"[Post-Clone Script] Executing: {command}")

                send_assistant_message(f"Executing post-clone scripts...")
                try:
                    bg = bash(command, background=True)
                    try:
                        result = bg.wait(timeout=60*5)
                    except TimeoutError:
                        log.warning(f"[Post-Clone Script] Timed out after 5m, killing process")
                        bg.kill()
                        _final_output = bg._stdout_buffer
                        send_assistant_message(f"Timed out after 5m, killing process")
                        if _final_output:
                            send_assistant_message(f"   Stdout:\n{_final_output}")
                        return True

                    if result.return_code != 0:
                        log.warning(f"[Post-Clone Script] Warning: exited with code {result.return_code}")
                        send_assistant_message(f"Warning: Post Clone Script exited with code {result.return_code}")
                        if result.stderr:
                            send_assistant_message(f"   stderr:\n{result.stderr}")
                    else:
                        send_assistant_message(f"Post Clone Script completed successfully")
                        log.info(f"[Post-Clone Script] Completed successfully")
                    return True
                except Exception as e:
                    log.error(f"[Post-Clone Script] Error: {e}")
                    return True

        return False

    def query(self, query: str, tail: bool = True) -> BackgroundCommandOutput:
        pass

    async def stream(self, prompt: str) -> AsyncIterator["Message"]:
        """
        Async generator that yields Message objects as they stream in.

        Usage:
            async for message in agent.stream("Tell me a joke"):
                print(message.content)

        Yields:
            Message objects from the agent, or tuples of (tool_name, args) for tool calls

        Raises:
            RuntimeError: If asyncio event loop is not available
        """
        import asyncio

        queue: asyncio.Queue = asyncio.Queue()
        loop = asyncio.get_event_loop()

        # Bridge callbacks from sync notification system to async queue
        def on_message(notification: NotifyMessageArgs):
            """Bridge from sync callback to async queue"""
            loop.call_soon_threadsafe(queue.put_nowait, ("message", notification.message))

        def on_tool_call(notification: NotifyToolCallArgs):
            """Bridge from sync callback to async queue"""
            loop.call_soon_threadsafe(
                queue.put_nowait,
                ("tool_call", (notification.tool_name, notification.serialized_args))
            )

        def on_complete(notification: NotifyCompleteArgs):
            """Signal completion by pushing sentinel"""
            loop.call_soon_threadsafe(queue.put_nowait, ("complete", notification.last_message))

        # Register callbacks for streaming
        self.register_notification(self.Notifications.NOTIFY_MESSAGE_V2, on_message)
        self.register_notification(self.Notifications.NOTIFY_COMPLETE_V2, on_complete)
        self.register_notification(self.Notifications.NOTIFY_TOOL_CALL_V2, on_tool_call)

        try:
            # Start the query (non-blocking, returns immediately)
            self.query(prompt)

            # Yield messages until completion
            while True:
                event_type, payload = await queue.get()
                if event_type == "complete":
                    break
                elif event_type == "tool_call":
                    yield payload
                elif event_type == "message":
                    yield payload
        finally:
            # Clean up callbacks to prevent memory leaks on repeated calls
            self.unregister_notification(self.Notifications.NOTIFY_MESSAGE_V2, on_message)
            self.unregister_notification(self.Notifications.NOTIFY_COMPLETE_V2, on_complete)
            self.unregister_notification(self.Notifications.NOTIFY_TOOL_CALL_V2, on_tool_call)

    def restart_session(self):
        self.is_session_active = False

    def warm_up_mcp(self, default_packages: List[str] = None) -> Dict[str, Any]:
        """
        Pre-install MCP (Model Context Protocol) packages that use npx.
        Reads configuration from ~/.config/blocks/mcp.json and installs
        all npx-based packages globally using npm.
        
        Args:
            default_packages: List of default packages to always install (with optional versions)
                             e.g., ["mcp-remote"]
        
        Returns:
            Dict containing status, installed packages, and any errors
        """
        from blocks import bash
        
        # Default package(s) to always install
        if default_packages is None:
            default_packages = ["mcp-remote"]
        
        result = {
            "status": "success",
            "packages": [],
            "errors": [],
            "message": ""
        }
        
        try:
            # Construct path to MCP config
            mcp_config_path = Path.home() / ".config" / "blocks" / "mcp.json"
            
            # Check if config file exists
            if not mcp_config_path.exists():
                result["status"] = "skipped"
                result["message"] = f"MCP config not found at {mcp_config_path}"
                log.info(f"[warm_up_mcp] {result['message']}")
                return result
            
            # Read and parse MCP configuration
            with open(mcp_config_path, "r") as f:
                mcp_config = json.load(f)
            
            if not mcp_config:
                result["status"] = "skipped"
                result["message"] = "MCP config is empty"
                log.info(f"[warm_up_mcp] {result['message']}")
                return result
            
            # Extract npx-based packages
            npx_packages = []
            for server_name, server_config in mcp_config.items():
                # Skip disabled servers
                if server_config.get("disabled", False):
                    log.info(f"[warm_up_mcp] Skipping disabled server: {server_name}")
                    continue
                
                # Check if this is an npx-based server
                if server_config.get("command") == "npx":
                    args = server_config.get("args", [])
                    
                    # Extract package name from args
                    # Typical format: ["-y", "@package/name@version"]
                    package_name = None
                    for arg in args:
                        # Skip flags
                        if arg.startswith("-"):
                            continue
                        # This should be the package name
                        package_name = arg
                        break
                    
                    if package_name:
                        # Keep the package name as-is, including version if specified
                        # Examples:
                        # - "@modelcontextprotocol/server-slack" (no version)
                        # - "@modelcontextprotocol/server-slack@latest" (with version)
                        # - "firecrawl-mcp@1.2.3" (non-scoped with version)
                        
                        npx_packages.append({
                            "name": package_name,
                            "server": server_name
                        })
                        log.info(f"[warm_up_mcp] Found npx package: {package_name} (server: {server_name})")

            # Add default packages
            for default_pkg in default_packages:
                log.info(f"[warm_up_mcp] Adding default package: {default_pkg}")
            
            # Prepare npm install command
            package_names = [pkg["name"] for pkg in npx_packages] + default_packages
            
            if not package_names:
                result["status"] = "skipped"
                result["message"] = "No packages to install"
                log.info(f"[warm_up_mcp] {result['message']}")
                return result
            
            result["packages"] = package_names
            
            # Run npm install globally
            install_command = f"npm install -g {' '.join(package_names)}"
            log.info(f"[warm_up_mcp] Running: {install_command}")

            # Execute the installation
            install_result = bash(install_command, suppress_exception=True)

            if install_result.return_code == 0:
                result["status"] = "success"
                result["message"] = f"Successfully installed {len(package_names)} package(s)"
                log.info(f"[warm_up_mcp] {result['message']}")
                log.info(f"[warm_up_mcp] Installed packages: {', '.join(package_names)}")
            else:
                result["status"] = "partial"
                result["message"] = f"Installation completed with warnings/errors"
                result["errors"].append(install_result.stderr)
                log.warning(f"[warm_up_mcp] {result['message']}")
                log.warning(f"[warm_up_mcp] stderr: {install_result.stderr}")
            
        except json.JSONDecodeError as e:
            result["status"] = "error"
            result["message"] = f"Failed to parse MCP config: {e}"
            result["errors"].append(str(e))
            log.error(f"[warm_up_mcp] Error: {result['message']}")
        except Exception as e:
            result["status"] = "error"
            result["message"] = f"Unexpected error: {e}"
            result["errors"].append(str(e))
            log.error(f"[warm_up_mcp] Error: {result['message']}")

        return result

    @staticmethod
    def build_internal_mcp_config(port=8000, endpoint="sse"):
        """Build the standard blocks-internal-mcp config dict (Blocks format)."""
        return {
            "disabled": False,
            "timeout": 3000,
            "command": "npx",
            "args": ["-y", "mcp-remote", f"http://127.0.0.1:{port}/{endpoint}"]
        }

    def add_mcp(self, name: str, config: dict):
        """Register an MCP server config to be included in write_config()."""
        self._mcp_servers[name] = config

    def add_internal_mcp(self, port=8000, endpoint="sse", **overrides):
        """Add the blocks-internal-mcp server with optional overrides."""
        config = self.build_internal_mcp_config(port=port, endpoint=endpoint)
        config.update(overrides)
        patch_blocks_runtime_config({
            BlocksRuntimeConfigKeys.MCP_SERVER_PORT: port
        })
        self.add_mcp("blocks-internal-mcp", config)
