"""GitHub MCP Client for communicating with the GitHub MCP server.

This module provides a client that can:
1. Start the GitHub MCP server as a subprocess (local mode)
2. Send tool calls via MCP protocol (JSON-RPC over stdio)
3. Parse responses and handle errors

The GitHub MCP server exposes GitHub API functionality via MCP protocol,
enabling code search, PR analysis, file content retrieval, and more.
"""

import json
import os
import shutil
import subprocess
import threading
import uuid
from dataclasses import dataclass, field
from typing import Any, Optional

from emdash.core.config import get_config
from emdash.utils.logger import log


class MCPError(Exception):
    """Exception raised for MCP-related errors."""

    def __init__(self, message: str, code: Optional[int] = None, data: Any = None):
        super().__init__(message)
        self.code = code
        self.data = data


@dataclass
class MCPToolInfo:
    """Information about an available MCP tool."""

    name: str
    description: str
    input_schema: dict = field(default_factory=dict)


@dataclass
class MCPResponse:
    """Response from an MCP tool call."""

    success: bool
    result: Any = None
    error: Optional[str] = None
    error_code: Optional[int] = None


class GitHubMCPClient:
    """Client for communicating with the GitHub MCP server.

    This client manages the lifecycle of the GitHub MCP server subprocess
    and provides methods to call MCP tools.

    Example:
        client = GitHubMCPClient()
        client.start()

        # List available tools
        tools = client.list_tools()

        # Call a tool
        result = client.call_tool("get_file_contents", {
            "owner": "facebook",
            "repo": "react",
            "path": "README.md"
        })

        client.stop()

    Can also be used as a context manager:
        with GitHubMCPClient() as client:
            result = client.call_tool("search_code", {"query": "useState"})
    """

    def __init__(
        self,
        token: Optional[str] = None,
        binary_path: Optional[str] = None,
        toolsets: Optional[list[str]] = None,
        read_only: bool = False,
        timeout: int = 30,
    ):
        """Initialize the GitHub MCP client.

        Args:
            token: GitHub personal access token. If None, reads from config/env.
            binary_path: Path to github-mcp-server binary. If None, uses config.
            toolsets: List of toolsets to enable. If None, uses config defaults.
            read_only: Whether to run in read-only mode (safer).
            timeout: Timeout in seconds for tool calls.
        """
        config = get_config()

        self.token = token or config.mcp.github_token
        self.binary_path = binary_path or config.mcp.binary_path
        self.toolsets = toolsets or config.mcp.toolsets
        self.read_only = read_only
        self.timeout = timeout

        self._process: Optional[subprocess.Popen] = None
        self._request_id = 0
        self._lock = threading.Lock()
        self._tools: dict[str, MCPToolInfo] = {}
        self._initialized = False

    def __enter__(self) -> "GitHubMCPClient":
        """Context manager entry."""
        self.start()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        """Context manager exit."""
        self.stop()

    @property
    def is_available(self) -> bool:
        """Check if the MCP client can be used."""
        if not self.token:
            return False
        # Check if binary exists
        return self._find_binary() is not None

    def _find_binary(self) -> Optional[str]:
        """Find the github-mcp-server binary."""
        # Check configured path first
        if os.path.isfile(self.binary_path):
            return self.binary_path

        # Check common locations
        paths_to_check = [
            shutil.which("github-mcp-server"),
            "/opt/homebrew/bin/github-mcp-server",
            "/usr/local/bin/github-mcp-server",
            "/usr/bin/github-mcp-server",
            os.path.expanduser("~/.local/bin/github-mcp-server"),
        ]

        for path in paths_to_check:
            if path and os.path.isfile(path):
                return path

        return None

    def start(self) -> None:
        """Start the GitHub MCP server subprocess.

        Raises:
            MCPError: If the server cannot be started.
        """
        if self._process is not None:
            log.debug("MCP server already running")
            return

        binary = self._find_binary()
        if not binary:
            raise MCPError(
                f"GitHub MCP server binary not found. "
                f"Download from https://github.com/github/github-mcp-server/releases "
                f"and ensure it's in PATH or set MCP_BINARY_PATH."
            )

        if not self.token:
            raise MCPError(
                "GitHub token not configured. "
                "Set GITHUB_TOKEN or GITHUB_PERSONAL_ACCESS_TOKEN environment variable."
            )

        # Build command - note: must use 'stdio' subcommand for JSON-RPC communication
        cmd = [binary, "stdio"]
        if self.toolsets:
            cmd.extend(["--toolsets", ",".join(self.toolsets)])
        if self.read_only:
            cmd.append("--read-only")

        # Set up environment
        env = os.environ.copy()
        env["GITHUB_PERSONAL_ACCESS_TOKEN"] = self.token

        log.debug(f"Starting MCP server: {' '.join(cmd)}")

        try:
            self._process = subprocess.Popen(
                cmd,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                env=env,
                text=True,
                bufsize=1,  # Line buffered
            )
        except FileNotFoundError:
            raise MCPError(f"Failed to start MCP server: binary not found at {binary}")
        except Exception as e:
            raise MCPError(f"Failed to start MCP server: {e}")

        # Initialize the connection
        self._initialize()

    def _initialize(self) -> None:
        """Initialize the MCP connection by sending initialize request."""
        if self._initialized:
            return

        # Send initialize request
        response = self._send_request("initialize", {
            "protocolVersion": "2024-11-05",
            "capabilities": {
                "tools": {},
            },
            "clientInfo": {
                "name": "emdash",
                "version": "1.0.0",
            },
        })

        if response.get("error"):
            raise MCPError(
                f"MCP initialization failed: {response['error'].get('message', 'Unknown error')}",
                code=response["error"].get("code"),
            )

        # Send initialized notification
        self._send_notification("notifications/initialized", {})

        self._initialized = True
        log.debug("MCP server initialized successfully")

    def stop(self) -> None:
        """Stop the GitHub MCP server subprocess."""
        if self._process is None:
            return

        try:
            # Send shutdown request (graceful shutdown)
            self._process.stdin.close()
            self._process.terminate()
            self._process.wait(timeout=5)
        except subprocess.TimeoutExpired:
            self._process.kill()
            self._process.wait()
        except Exception as e:
            log.warning(f"Error stopping MCP server: {e}")

        self._process = None
        self._initialized = False
        self._tools = {}
        log.debug("MCP server stopped")

    def _next_request_id(self) -> int:
        """Get the next request ID."""
        with self._lock:
            self._request_id += 1
            return self._request_id

    def _send_request(self, method: str, params: dict) -> dict:
        """Send a JSON-RPC request and wait for response.

        Args:
            method: The RPC method name
            params: Method parameters

        Returns:
            The response dict

        Raises:
            MCPError: If communication fails
        """
        if self._process is None or self._process.poll() is not None:
            raise MCPError("MCP server is not running")

        request_id = self._next_request_id()
        request = {
            "jsonrpc": "2.0",
            "id": request_id,
            "method": method,
            "params": params,
        }

        try:
            request_json = json.dumps(request)
            log.debug(f"MCP request: {method}")

            self._process.stdin.write(request_json + "\n")
            self._process.stdin.flush()

            # Read responses until we get the one with our request ID
            # MCP servers can send notifications before the actual response
            max_attempts = 50  # Prevent infinite loops
            for _ in range(max_attempts):
                response_line = self._process.stdout.readline()
                if not response_line:
                    # Check if process died
                    if self._process.poll() is not None:
                        stderr = self._process.stderr.read()
                        raise MCPError(f"MCP server died unexpectedly: {stderr}")
                    raise MCPError("No response from MCP server")

                response = json.loads(response_line)
                
                # Check if this is a notification (no id) - skip it
                if "id" not in response:
                    log.debug(f"MCP notification: {response.get('method', 'unknown')}")
                    continue
                
                # Check if this is our response
                if response.get("id") == request_id:
                    return response
                
                # Different request ID - log and continue
                log.debug(f"MCP response for different request: {response.get('id')}")
            
            raise MCPError("Timed out waiting for MCP response")

        except json.JSONDecodeError as e:
            raise MCPError(f"Invalid JSON response from MCP server: {e}")
        except Exception as e:
            if isinstance(e, MCPError):
                raise
            raise MCPError(f"MCP communication error: {e}")

    def _send_notification(self, method: str, params: dict) -> None:
        """Send a JSON-RPC notification (no response expected).

        Args:
            method: The notification method name
            params: Notification parameters
        """
        if self._process is None:
            return

        notification = {
            "jsonrpc": "2.0",
            "method": method,
            "params": params,
        }

        try:
            notification_json = json.dumps(notification)
            self._process.stdin.write(notification_json + "\n")
            self._process.stdin.flush()
        except Exception as e:
            log.warning(f"Failed to send notification: {e}")

    def list_tools(self) -> list[MCPToolInfo]:
        """List all available tools from the MCP server.

        Returns:
            List of available tools with their schemas.

        Raises:
            MCPError: If the request fails.
        """
        if not self._initialized:
            self.start()

        response = self._send_request("tools/list", {})

        if response.get("error"):
            raise MCPError(
                f"Failed to list tools: {response['error'].get('message', 'Unknown error')}",
                code=response["error"].get("code"),
            )

        tools = []
        for tool_data in response.get("result", {}).get("tools", []):
            tool = MCPToolInfo(
                name=tool_data.get("name", ""),
                description=tool_data.get("description", ""),
                input_schema=tool_data.get("inputSchema", {}),
            )
            tools.append(tool)
            self._tools[tool.name] = tool

        return tools

    def get_tool(self, name: str) -> Optional[MCPToolInfo]:
        """Get information about a specific tool.

        Args:
            name: Tool name

        Returns:
            Tool info or None if not found
        """
        if not self._tools:
            self.list_tools()
        return self._tools.get(name)

    def call_tool(self, name: str, arguments: dict) -> MCPResponse:
        """Call an MCP tool with the given arguments.

        Args:
            name: Tool name (e.g., "get_file_contents", "search_code")
            arguments: Tool-specific arguments

        Returns:
            MCPResponse with the result or error

        Example:
            response = client.call_tool("get_file_contents", {
                "owner": "facebook",
                "repo": "react",
                "path": "README.md"
            })
            if response.success:
                print(response.result)
        """
        if not self._initialized:
            self.start()

        log.debug(f"Calling MCP tool: {name}")

        response = self._send_request("tools/call", {
            "name": name,
            "arguments": arguments,
        })

        if response.get("error"):
            error = response["error"]
            return MCPResponse(
                success=False,
                error=error.get("message", "Unknown error"),
                error_code=error.get("code"),
            )

        result = response.get("result", {})

        # Handle content array format
        content = result.get("content", [])
        if content:
            # MCP returns content as array of content blocks
            # Block types: "text" (plain text or JSON), "resource" (file content with metadata)
            
            # First, check if there's a resource block with actual content (e.g., file contents)
            for block in content:
                if block.get("type") == "resource":
                    resource = block.get("resource", {})
                    if "text" in resource:
                        return MCPResponse(success=True, result=resource["text"])
            
            # If single text block, try to parse as JSON (common for API responses)
            if len(content) == 1 and content[0].get("type") == "text":
                text = content[0].get("text", "")
                try:
                    parsed = json.loads(text)
                    return MCPResponse(success=True, result=parsed)
                except json.JSONDecodeError:
                    return MCPResponse(success=True, result=text)
            
            # Multiple content blocks - try to extract meaningful data
            # Check if any text block contains JSON (API responses)
            for block in content:
                if block.get("type") == "text":
                    text = block.get("text", "")
                    try:
                        parsed = json.loads(text)
                        return MCPResponse(success=True, result=parsed)
                    except json.JSONDecodeError:
                        continue
            
            # Fallback: return all content blocks for the tool to handle
            return MCPResponse(success=True, result=content)

        # Check for isError flag
        if result.get("isError"):
            return MCPResponse(
                success=False,
                error=str(result),
            )

        return MCPResponse(success=True, result=result)

    # Convenience methods for common operations

    def search_code(
        self,
        query: str,
        owner: Optional[str] = None,
        repo: Optional[str] = None,
        per_page: int = 10,
    ) -> MCPResponse:
        """Search for code across GitHub repositories.

        Args:
            query: Search query (e.g., "useState language:typescript")
            owner: Optional repository owner to scope search
            repo: Optional repository name to scope search
            per_page: Number of results per page (max 100)

        Returns:
            MCPResponse with search results
        """
        args = {"query": query, "perPage": per_page}
        if owner and repo:
            args["query"] = f"{query} repo:{owner}/{repo}"
        return self.call_tool("search_code", args)

    def get_file_contents(
        self,
        owner: str,
        repo: str,
        path: str,
        branch: Optional[str] = None,
    ) -> MCPResponse:
        """Get the contents of a file from a GitHub repository.

        Args:
            owner: Repository owner
            repo: Repository name
            path: File path within the repository
            branch: Optional branch/ref name

        Returns:
            MCPResponse with file contents
        """
        args = {"owner": owner, "repo": repo, "path": path}
        if branch:
            args["branch"] = branch
        return self.call_tool("get_file_contents", args)

    def get_pull_request(
        self,
        owner: str,
        repo: str,
        pull_number: int,
    ) -> MCPResponse:
        """Get details of a pull request.

        Args:
            owner: Repository owner
            repo: Repository name
            pull_number: PR number

        Returns:
            MCPResponse with PR details
        """
        return self.call_tool("pull_request_read", {
            "method": "get",
            "owner": owner,
            "repo": repo,
            "pullNumber": pull_number,
        })

    def get_pull_request_diff(
        self,
        owner: str,
        repo: str,
        pull_number: int,
    ) -> MCPResponse:
        """Get the diff of a pull request.

        Args:
            owner: Repository owner
            repo: Repository name
            pull_number: PR number

        Returns:
            MCPResponse with PR diff
        """
        return self.call_tool("pull_request_read", {
            "method": "get_diff",
            "owner": owner,
            "repo": repo,
            "pullNumber": pull_number,
        })

    def get_pull_request_comments(
        self,
        owner: str,
        repo: str,
        pull_number: int,
    ) -> MCPResponse:
        """Get comments on a pull request.

        Args:
            owner: Repository owner
            repo: Repository name
            pull_number: PR number

        Returns:
            MCPResponse with PR comments
        """
        return self.call_tool("pull_request_read", {
            "method": "get_comments",
            "owner": owner,
            "repo": repo,
            "pullNumber": pull_number,
        })

    def get_pull_request_reviews(
        self,
        owner: str,
        repo: str,
        pull_number: int,
    ) -> MCPResponse:
        """Get reviews on a pull request.

        Args:
            owner: Repository owner
            repo: Repository name
            pull_number: PR number

        Returns:
            MCPResponse with PR reviews
        """
        return self.call_tool("pull_request_read", {
            "method": "get_reviews",
            "owner": owner,
            "repo": repo,
            "pullNumber": pull_number,
        })

    def get_pull_request_review_comments(
        self,
        owner: str,
        repo: str,
        pull_number: int,
    ) -> MCPResponse:
        """Get review comments (inline code comments) on a pull request.

        These are the actual comments made on specific lines of code during reviews,
        as opposed to the top-level review actions (approve/request changes).

        Args:
            owner: Repository owner
            repo: Repository name
            pull_number: PR number

        Returns:
            MCPResponse with review comments
        """
        return self.call_tool("pull_request_read", {
            "method": "get_review_comments",
            "owner": owner,
            "repo": repo,
            "pullNumber": pull_number,
        })

    def create_pull_request_review(
        self,
        owner: str,
        repo: str,
        pull_number: int,
        body: str,
        event: str = "COMMENT",
        comments: Optional[list[dict]] = None,
    ) -> MCPResponse:
        """Create a review on a pull request.

        This posts a review with optional inline comments. Requires non-read-only mode.

        Args:
            owner: Repository owner
            repo: Repository name
            pull_number: PR number
            body: The body text of the review summary
            event: Review action - 'APPROVE', 'REQUEST_CHANGES', or 'COMMENT'
            comments: Optional list of inline comment dicts with keys:
                - path: File path relative to repo root
                - line: Line number in the diff (use 'position' for old API)
                - body: Comment text
                - side: 'LEFT' or 'RIGHT' (default RIGHT for new code)

        Returns:
            MCPResponse with the created review

        Raises:
            MCPError: If called in read-only mode or if the call fails
        """
        if self.read_only:
            return MCPResponse(
                success=False,
                error="Cannot create review in read-only mode. Initialize client with read_only=False",
            )

        args = {
            "owner": owner,
            "repo": repo,
            "pullNumber": pull_number,
            "body": body,
            "event": event,
            "method": "create",  # Specify the method to create a new review
        }

        if comments:
            args["comments"] = comments

        return self.call_tool("pull_request_review_write", args)

    def list_pull_requests(
        self,
        owner: str,
        repo: str,
        state: str = "open",
        per_page: int = 30,
    ) -> MCPResponse:
        """List pull requests in a repository.

        Args:
            owner: Repository owner
            repo: Repository name
            state: PR state filter (open, closed, all)
            per_page: Number of results per page

        Returns:
            MCPResponse with list of PRs
        """
        return self.call_tool("list_pull_requests", {
            "owner": owner,
            "repo": repo,
            "state": state,
            "perPage": per_page,
        })

    def search_repositories(
        self,
        query: str,
        per_page: int = 10,
    ) -> MCPResponse:
        """Search for GitHub repositories.

        Args:
            query: Search query
            per_page: Number of results per page

        Returns:
            MCPResponse with matching repositories
        """
        return self.call_tool("search_repositories", {
            "query": query,
            "perPage": per_page,
        })

    def search_issues(
        self,
        query: str,
        per_page: int = 10,
    ) -> MCPResponse:
        """Search for GitHub issues/PRs by query.

        Args:
            query: Search query
            per_page: Number of results per page

        Returns:
            MCPResponse with matching issues/PRs
        """
        return self.call_tool("search_issues", {
            "query": query,
            "perPage": per_page,
        })

    def get_issue(
        self,
        owner: str,
        repo: str,
        issue_number: int,
    ) -> MCPResponse:
        """Get details of an issue.

        Args:
            owner: Repository owner
            repo: Repository name
            issue_number: Issue number

        Returns:
            MCPResponse with issue details
        """
        return self.call_tool("issue_read", {
            "method": "get",
            "owner": owner,
            "repo": repo,
            "issue_number": issue_number,
        })


class GenericMCPClient:
    """Generic MCP client that works with any MCP server.

    Unlike GitHubMCPClient, this client:
    - Takes server config as input (command, args, env)
    - Does not have hardcoded tool convenience methods
    - Provides raw call_tool() and list_tools() functionality

    Example:
        client = GenericMCPClient(
            name="filesystem",
            command="npx",
            args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
        )
        client.start()
        tools = client.list_tools()
        result = client.call_tool("read_file", {"path": "/tmp/test.txt"})
        client.stop()
    """

    def __init__(
        self,
        name: str,
        command: str,
        args: Optional[list[str]] = None,
        env: Optional[dict[str, str]] = None,
        timeout: int = 30,
    ):
        """Initialize a generic MCP client.

        Args:
            name: Unique identifier for this server
            command: Command to run (e.g., "npx", "github-mcp-server")
            args: Arguments to pass to the command
            env: Environment variables to set
            timeout: Timeout in seconds for tool calls
        """
        self.name = name
        self.command = command
        self.args = args or []
        self.env = env or {}
        self.timeout = timeout

        self._process: Optional[subprocess.Popen] = None
        self._request_id = 0
        self._lock = threading.Lock()
        self._tools: dict[str, MCPToolInfo] = {}
        self._initialized = False

    def __enter__(self) -> "GenericMCPClient":
        """Context manager entry."""
        self.start()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        """Context manager exit."""
        self.stop()

    @property
    def is_running(self) -> bool:
        """Check if the MCP server is running."""
        return self._process is not None and self._process.poll() is None

    def _find_command(self) -> Optional[str]:
        """Find the command executable.

        Returns:
            Path to the command or None if not found
        """
        # Check if it's an absolute path
        if os.path.isfile(self.command):
            return self.command

        # Check PATH using shutil.which
        found = shutil.which(self.command)
        if found:
            return found

        # Check common locations
        common_paths = [
            "/opt/homebrew/bin",
            "/usr/local/bin",
            "/usr/bin",
            os.path.expanduser("~/.local/bin"),
            os.path.expanduser("~/.npm-global/bin"),
        ]

        for base in common_paths:
            path = os.path.join(base, self.command)
            if os.path.isfile(path):
                return path

        return None

    def start(self) -> None:
        """Start the MCP server subprocess.

        Raises:
            MCPError: If the server cannot be started
        """
        if self._process is not None:
            log.debug(f"MCP server '{self.name}' already running")
            return

        cmd_path = self._find_command()
        if not cmd_path:
            raise MCPError(
                f"MCP server command not found: {self.command}. "
                f"Ensure it's installed and in PATH."
            )

        # Build full command
        cmd = [cmd_path] + self.args

        # Merge environment
        env = os.environ.copy()
        env.update(self.env)

        log.info(f"Starting MCP server '{self.name}': {' '.join(cmd)}")

        try:
            self._process = subprocess.Popen(
                cmd,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                env=env,
                text=True,
                bufsize=1,  # Line buffered
            )
        except FileNotFoundError:
            raise MCPError(f"Failed to start MCP server: command not found at {cmd_path}")
        except Exception as e:
            raise MCPError(f"Failed to start MCP server '{self.name}': {e}")

        # Initialize the connection
        self._initialize()

    def _initialize(self) -> None:
        """Initialize the MCP connection by sending initialize request."""
        if self._initialized:
            return

        # Send initialize request
        response = self._send_request("initialize", {
            "protocolVersion": "2024-11-05",
            "capabilities": {
                "tools": {},
            },
            "clientInfo": {
                "name": "emdash",
                "version": "1.0.0",
            },
        })

        if response.get("error"):
            raise MCPError(
                f"MCP initialization failed for '{self.name}': "
                f"{response['error'].get('message', 'Unknown error')}",
                code=response["error"].get("code"),
            )

        # Send initialized notification
        self._send_notification("notifications/initialized", {})

        self._initialized = True
        log.info(f"MCP server '{self.name}' initialized successfully")

    def stop(self) -> None:
        """Stop the MCP server subprocess."""
        if self._process is None:
            return

        try:
            self._process.stdin.close()
            self._process.terminate()
            self._process.wait(timeout=5)
        except subprocess.TimeoutExpired:
            self._process.kill()
            self._process.wait()
        except Exception as e:
            log.warning(f"Error stopping MCP server '{self.name}': {e}")

        self._process = None
        self._initialized = False
        self._tools = {}
        log.debug(f"MCP server '{self.name}' stopped")

    def _next_request_id(self) -> int:
        """Get the next request ID."""
        with self._lock:
            self._request_id += 1
            return self._request_id

    def _send_request(self, method: str, params: dict) -> dict:
        """Send a JSON-RPC request and wait for response.

        Args:
            method: The RPC method name
            params: Method parameters

        Returns:
            The response dict

        Raises:
            MCPError: If communication fails
        """
        if self._process is None or self._process.poll() is not None:
            raise MCPError(f"MCP server '{self.name}' is not running")

        request_id = self._next_request_id()
        request = {
            "jsonrpc": "2.0",
            "id": request_id,
            "method": method,
            "params": params,
        }

        try:
            request_json = json.dumps(request)
            log.debug(f"MCP request to '{self.name}': {method}")

            self._process.stdin.write(request_json + "\n")
            self._process.stdin.flush()

            # Read responses until we get the one with our request ID
            max_attempts = 50
            for _ in range(max_attempts):
                response_line = self._process.stdout.readline()
                if not response_line:
                    if self._process.poll() is not None:
                        stderr = self._process.stderr.read()
                        raise MCPError(f"MCP server '{self.name}' died: {stderr}")
                    raise MCPError(f"No response from MCP server '{self.name}'")

                response = json.loads(response_line)

                # Check if this is a notification (no id) - skip it
                if "id" not in response:
                    log.debug(f"MCP notification from '{self.name}': {response.get('method', 'unknown')}")
                    continue

                # Check if this is our response
                if response.get("id") == request_id:
                    return response

                log.debug(f"MCP response for different request: {response.get('id')}")

            raise MCPError(f"Timed out waiting for MCP response from '{self.name}'")

        except json.JSONDecodeError as e:
            raise MCPError(f"Invalid JSON response from MCP server '{self.name}': {e}")
        except Exception as e:
            if isinstance(e, MCPError):
                raise
            raise MCPError(f"MCP communication error with '{self.name}': {e}")

    def _send_notification(self, method: str, params: dict) -> None:
        """Send a JSON-RPC notification (no response expected)."""
        if self._process is None:
            return

        notification = {
            "jsonrpc": "2.0",
            "method": method,
            "params": params,
        }

        try:
            notification_json = json.dumps(notification)
            self._process.stdin.write(notification_json + "\n")
            self._process.stdin.flush()
        except Exception as e:
            log.warning(f"Failed to send notification to '{self.name}': {e}")

    def list_tools(self) -> list[MCPToolInfo]:
        """List all available tools from the MCP server.

        Returns:
            List of available tools with their schemas

        Raises:
            MCPError: If the request fails
        """
        if not self._initialized:
            self.start()

        response = self._send_request("tools/list", {})

        if response.get("error"):
            raise MCPError(
                f"Failed to list tools from '{self.name}': "
                f"{response['error'].get('message', 'Unknown error')}",
                code=response["error"].get("code"),
            )

        tools = []
        for tool_data in response.get("result", {}).get("tools", []):
            tool = MCPToolInfo(
                name=tool_data.get("name", ""),
                description=tool_data.get("description", ""),
                input_schema=tool_data.get("inputSchema", {}),
            )
            tools.append(tool)
            self._tools[tool.name] = tool

        log.info(f"MCP server '{self.name}' has {len(tools)} tools")
        return tools

    def get_tool(self, name: str) -> Optional[MCPToolInfo]:
        """Get information about a specific tool."""
        if not self._tools:
            self.list_tools()
        return self._tools.get(name)

    def call_tool(self, name: str, arguments: dict) -> MCPResponse:
        """Call an MCP tool with the given arguments.

        Args:
            name: Tool name
            arguments: Tool-specific arguments

        Returns:
            MCPResponse with the result or error
        """
        if not self._initialized:
            self.start()

        log.debug(f"Calling MCP tool '{name}' on server '{self.name}'")

        response = self._send_request("tools/call", {
            "name": name,
            "arguments": arguments,
        })

        if response.get("error"):
            error = response["error"]
            return MCPResponse(
                success=False,
                error=error.get("message", "Unknown error"),
                error_code=error.get("code"),
            )

        result = response.get("result", {})

        # Handle content array format (same as GitHubMCPClient)
        content = result.get("content", [])
        if content:
            # Check for resource block first
            for block in content:
                if block.get("type") == "resource":
                    resource = block.get("resource", {})
                    if "text" in resource:
                        return MCPResponse(success=True, result=resource["text"])

            # Try to parse text as JSON
            if len(content) == 1 and content[0].get("type") == "text":
                text = content[0].get("text", "")
                try:
                    parsed = json.loads(text)
                    return MCPResponse(success=True, result=parsed)
                except json.JSONDecodeError:
                    return MCPResponse(success=True, result=text)

            # Multiple content blocks
            for block in content:
                if block.get("type") == "text":
                    text = block.get("text", "")
                    try:
                        parsed = json.loads(text)
                        return MCPResponse(success=True, result=parsed)
                    except json.JSONDecodeError:
                        continue

            return MCPResponse(success=True, result=content)

        if result.get("isError"):
            return MCPResponse(success=False, error=str(result))

        return MCPResponse(success=True, result=result)


# Global client instance (lazy initialization)
_mcp_client: Optional[GitHubMCPClient] = None


def get_mcp_client() -> GitHubMCPClient:
    """Get or create the global MCP client instance.

    Returns:
        GitHubMCPClient instance (not started)
    """
    global _mcp_client
    if _mcp_client is None:
        _mcp_client = GitHubMCPClient()
    return _mcp_client
