"""
Cube MCP Server — EdgescaleAI Cube management and Apollo deployments.

Tools:
Setup:
- setup_check: Check if all prerequisites are installed
- setup_install_teleport: Install Teleport CLI (tsh)
- setup_install_kubectl: Install kubectl
- setup_install_helm: Install Helm
- setup_install_docker: Install Docker
- setup_install_apollo: Guide Apollo CLI installation
- setup_configure_credentials: Help configure Apollo credentials

Cube Management:
- cube_login: Authenticate to a Cube via Teleport (SSO)
- cube_login_password: Authenticate via username/password
- cube_status: Get Cube node status via kubectl describe node
- cube_list: List available Cubes via Teleport

App Proxy:
- app_list: List available Teleport apps
- app_login: Login to a Teleport app (get certificate)
- app_proxy: Start local proxy for an app (TCP/HTTP)
- app_proxy_stop: Stop a running app proxy
- app_proxy_status: Show status of running proxies

Apollo/ACR:
- acr_get_token: Get OAuth2 access token from Apollo Container Registry
- acr_login: Login to Apollo Container Registry (Docker + Helm)
- build_and_publish_to_apollo: Build Docker image, package Helm chart, and publish to Apollo

Apollo Environment Management:
- list_environments: List/search Apollo environments by name
- create_environment: Create a new environment with Apollo Control Plane module
- replicate_environment: Clone modules, entities, and configs from one environment to another
- install_entity: Install a single Helm chart entity on an environment
- entity_health: Get health and activity status of entities in an environment
- plan_details: Deep-dive into a specific entity's plan (tasks, events, error logs)
"""

import subprocess
import os
import re
import json
import glob
import platform
import shutil
import signal
import atexit
from mcp.server import FastMCP

# EdgescaleAI Teleport proxy URL
DEFAULT_PROXY = "edgescaleai.teleport.sh"

# Apollo Container Registry (ACR) configuration
ACR_REGISTRY = "edgescaleai.palantirapollo.com"
ACR_DOCKER_PREFIX = f"{ACR_REGISTRY}/com.edgescaleai-cube"
ACR_HELM_PREFIX = f"oci://{ACR_REGISTRY}/charts/com.edgescaleai-cube"
ACR_TOKEN_URL = f"https://{ACR_REGISTRY}/multipass/api/oauth2/token"
APOLLO_URL = f"https://{ACR_REGISTRY}"
APOLLO_GRAPHQL_URL = f"https://{ACR_REGISTRY}/graphql-gateway/api/graphql"
APOLLO_GRAPHQL_USER_AGENT = "apollo-sdk/0.0.15 forge-graphql-client/4.93.0"
APOLLO_GROUP_ID = "com.edgescaleai-cube"

mcp = FastMCP("cube-mcp")

# Track running app proxy processes
_running_proxies: dict[str, subprocess.Popen] = {}


def _cleanup_proxies():
    """Clean up all running proxy processes on exit."""
    for app_name, proc in list(_running_proxies.items()):
        try:
            proc.terminate()
            proc.wait(timeout=5)
        except:
            try:
                proc.kill()
            except:
                pass
    _running_proxies.clear()


# Register cleanup on exit
atexit.register(_cleanup_proxies)


# ============================================================================
# Helper Functions for ACR/Helm Operations
# ============================================================================

def _get_apollo_credentials():
    """Get Apollo client credentials from environment variables or apollo-cli profile."""
    client_id = os.environ.get("APOLLO_CLIENT")
    client_secret = os.environ.get("APOLLO_SECRET")

    if client_id and client_secret:
        return client_id, client_secret

    # Fall back to apollo-cli profile (export gives unmasked secrets)
    try:
        # Get current profile name
        show = subprocess.run(
            ["apollo-cli", "profile", "show", "--format", "json"],
            capture_output=True, text=True, timeout=10
        )
        if show.returncode == 0:
            profile_name = json.loads(show.stdout).get("name", "default")
            # Export with unmasked secrets
            export = subprocess.run(
                ["apollo-cli", "profile", "export", profile_name, "--format", "json"],
                capture_output=True, text=True, timeout=10
            )
            if export.returncode == 0:
                profile = json.loads(export.stdout)
                client_id = client_id or profile.get("apollo-client-id")
                client_secret = client_secret or profile.get("apollo-client-secret")
    except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError, OSError):
        pass

    return client_id, client_secret


def _get_acr_token() -> tuple[str, str]:
    """
    Get OAuth2 token from Apollo. Returns (token, error_message).
    """
    client_id, client_secret = _get_apollo_credentials()

    if not client_id or not client_secret:
        return None, """Apollo credentials not found.

Configure via apollo-cli:

  apollo-cli configure

Or set environment variables in ~/.bashrc or ~/.zshrc:

  export APOLLO_CLIENT="<your-client-id>"
  export APOLLO_SECRET="<your-client-secret>"
  source ~/.bashrc"""

    try:
        result = subprocess.run(
            [
                "curl", "-s", ACR_TOKEN_URL,
                "-H", "content-type: application/x-www-form-urlencoded",
                "--data-urlencode", "grant_type=client_credentials",
                "--data-urlencode", f"client_id={client_id}",
                "--data-urlencode", f"client_secret={client_secret}"
            ],
            capture_output=True,
            text=True,
            timeout=30
        )

        if result.returncode != 0:
            return None, f"Failed to get token: {result.stderr}"

        response = json.loads(result.stdout)
        token = response.get("access_token")

        if not token:
            return None, f"No access_token in response: {result.stdout}"

        return token, None

    except subprocess.TimeoutExpired:
        return None, "Token request timed out"
    except json.JSONDecodeError as e:
        return None, f"Invalid JSON response: {e}"
    except FileNotFoundError:
        return None, "curl not found. Please install curl."


def _find_chart_yaml(path: str = None) -> tuple[str, str]:
    """
    Find Chart.yaml. Returns (chart_path, error_message).

    Search order:
    1. If path specified, use it directly
    2. ./Chart.yaml
    3. ./charts/*/Chart.yaml
    """
    if path:
        if os.path.isfile(path):
            return path, None
        chart_yaml = os.path.join(path, "Chart.yaml")
        if os.path.isfile(chart_yaml):
            return chart_yaml, None
        return None, f"Chart.yaml not found at: {path}"

    # Check current directory
    if os.path.isfile("Chart.yaml"):
        return "Chart.yaml", None

    # Check charts subdirectory
    charts = glob.glob("charts/*/Chart.yaml")
    if len(charts) == 1:
        return charts[0], None
    elif len(charts) > 1:
        chart_names = [os.path.dirname(c) for c in charts]
        return None, f"Multiple charts found: {', '.join(chart_names)}. Please specify which one."

    return None, "No Chart.yaml found. Run from a chart directory or specify the path."


def _parse_chart_yaml(chart_path: str) -> tuple[dict, str]:
    """
    Parse Chart.yaml and return dict with name, version, appVersion.
    Returns (chart_info, error_message).
    """
    try:
        with open(chart_path, 'r') as f:
            content = f.read()

        # Simple YAML parsing for the fields we need
        name_match = re.search(r'^name:\s*(.+)$', content, re.MULTILINE)
        version_match = re.search(r'^version:\s*(.+)$', content, re.MULTILINE)
        app_version_match = re.search(r'^appVersion:\s*["\']?([^"\']+)["\']?$', content, re.MULTILINE)

        if not name_match or not version_match:
            return None, f"Could not parse name/version from {chart_path}"

        return {
            "name": name_match.group(1).strip(),
            "version": version_match.group(1).strip(),
            "appVersion": app_version_match.group(1).strip() if app_version_match else None,
            "path": chart_path,
            "dir": os.path.dirname(chart_path) or "."
        }, None

    except Exception as e:
        return None, f"Error reading {chart_path}: {e}"


def _bump_version(version: str, bump_type: str = "patch") -> str:
    """Bump a semver version string."""
    parts = version.split(".")
    if len(parts) != 3:
        return version  # Can't bump non-semver

    major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])

    if bump_type == "major":
        return f"{major + 1}.0.0"
    elif bump_type == "minor":
        return f"{major}.{minor + 1}.0"
    else:  # patch
        return f"{major}.{minor}.{patch + 1}"


def _update_chart_version(chart_path: str, new_version: str, new_app_version: str = None) -> tuple[bool, str]:
    """Update version (and optionally appVersion) in Chart.yaml."""
    try:
        with open(chart_path, 'r') as f:
            content = f.read()

        # Update version
        content = re.sub(
            r'^(version:\s*).+$',
            f'\\g<1>{new_version}',
            content,
            flags=re.MULTILINE
        )

        # Update appVersion if specified
        if new_app_version:
            content = re.sub(
                r'^(appVersion:\s*)["\']?[^"\']+["\']?$',
                f'\\g<1>"{new_app_version}"',
                content,
                flags=re.MULTILINE
            )

        with open(chart_path, 'w') as f:
            f.write(content)

        return True, None

    except Exception as e:
        return False, f"Error updating {chart_path}: {e}"


# ============================================================================
# Setup Tools - Help users install prerequisites
# ============================================================================

def _check_command(cmd: str) -> tuple[bool, str]:
    """Check if a command is available. Returns (installed, version_or_path)."""
    path = shutil.which(cmd)
    if not path:
        return False, ""

    # Try to get version
    version_flags = ["--version", "version", "-v"]
    for flag in version_flags:
        try:
            result = subprocess.run(
                [cmd, flag],
                capture_output=True,
                text=True,
                timeout=10
            )
            if result.returncode == 0:
                # Get first line of output
                version = result.stdout.strip().split('\n')[0]
                return True, version
        except:
            pass

    return True, path


def _get_platform() -> tuple[str, str]:
    """Get OS and architecture. Returns (os, arch)."""
    system = platform.system().lower()  # darwin, linux, windows
    machine = platform.machine().lower()  # x86_64, arm64, aarch64

    # Normalize architecture names
    if machine in ["x86_64", "amd64"]:
        arch = "amd64"
    elif machine in ["arm64", "aarch64"]:
        arch = "arm64"
    else:
        arch = machine

    return system, arch


@mcp.tool()
def setup_check() -> str:
    """
    Check if all prerequisites for cube-mcp are installed.

    This checks for:
    - tsh (Teleport CLI) - Required for Cube authentication
    - kubectl - Required for Cube management
    - helm - Required for chart packaging
    - docker - Required for building images
    - apollo-cli - Required for publishing to Apollo
    - Apollo credentials (via apollo-cli profile or APOLLO_CLIENT/APOLLO_SECRET env vars)
    """
    results = []
    all_good = True

    # Check CLI tools
    tools = [
        ("tsh", "Teleport CLI", "Required for Cube authentication"),
        ("kubectl", "Kubernetes CLI", "Required for Cube management"),
        ("helm", "Helm", "Required for chart packaging"),
        ("docker", "Docker", "Required for building images"),
        ("apollo-cli", "Apollo CLI", "Required for publishing to Apollo"),
    ]

    results.append("Prerequisites Status")
    results.append("=" * 50)

    for cmd, name, purpose in tools:
        installed, version = _check_command(cmd)
        if installed:
            results.append(f"✓ {name}: {version}")
        else:
            results.append(f"✗ {name}: NOT INSTALLED")
            results.append(f"  └─ {purpose}")
            all_good = False

    results.append("")
    results.append("Credentials Status")
    results.append("=" * 50)

    # Check Apollo credentials (env vars or apollo-cli profile)
    client_id, client_secret = _get_apollo_credentials()
    if client_id and client_secret:
        source = "env vars" if os.environ.get("APOLLO_CLIENT") else "apollo-cli profile"
        results.append(f"✓ Apollo credentials: {client_id[:8]}... (from {source})")
    else:
        results.append("✗ Apollo credentials: NOT CONFIGURED")
        results.append("  └─ Run 'apollo-cli configure' or set APOLLO_CLIENT/APOLLO_SECRET env vars")
        all_good = False

    # Check Teleport session
    results.append("")
    results.append("Session Status")
    results.append("=" * 50)

    try:
        tsh_status = subprocess.run(
            ["tsh", "status"],
            capture_output=True,
            text=True,
            timeout=10
        )
        if tsh_status.returncode == 0 and "EXPIRED" not in tsh_status.stdout:
            results.append("✓ Teleport: Logged in")
        else:
            results.append("✗ Teleport: Not logged in or session expired")
    except FileNotFoundError:
        results.append("✗ Teleport: tsh not installed")
    except:
        results.append("? Teleport: Could not check status")

    # Summary
    results.append("")
    results.append("=" * 50)
    if all_good:
        results.append("All prerequisites installed!")
    else:
        results.append("Some prerequisites are missing.")
        results.append("Use setup_install_* tools to install them.")

    return "\n".join(results)


def _has_brew() -> bool:
    """Check if Homebrew is available."""
    return shutil.which("brew") is not None


def _run_install(cmd: list[str], description: str) -> tuple[bool, str]:
    """Run an installation command. Returns (success, output)."""
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=300  # 5 min timeout for installs
        )
        if result.returncode == 0:
            return True, result.stdout
        else:
            return False, result.stderr
    except subprocess.TimeoutExpired:
        return False, "Installation timed out"
    except FileNotFoundError:
        return False, f"Command not found: {cmd[0]}"
    except Exception as e:
        return False, str(e)


@mcp.tool()
def setup_install_teleport() -> str:
    """
    Install Teleport CLI (tsh) for Cube authentication.

    Attempts to install via Homebrew (macOS) or official install script (Linux).
    """
    system, arch = _get_platform()

    # Check if already installed
    installed, version = _check_command("tsh")
    if installed:
        return f"Teleport (tsh) is already installed.\n\nVersion: {version}"

    if system == "darwin":
        # macOS - try Homebrew first
        if _has_brew():
            result = ["Installing Teleport via Homebrew..."]
            success, output = _run_install(["brew", "install", "teleport"], "Teleport")

            if success:
                installed, version = _check_command("tsh")
                result.append(f"✓ Teleport installed successfully!")
                result.append(f"  Version: {version}")
                return "\n".join(result)
            else:
                result.append(f"✗ Homebrew install failed: {output}")
                result.append("")
                result.append("Try manual installation:")
                result.append("  curl -O https://cdn.teleport.dev/teleport-v18.6.6.pkg")
                result.append("  sudo installer -pkg teleport-v18.6.6.pkg -target /")
                return "\n".join(result)
        else:
            return """Teleport CLI (tsh) Installation - macOS

Homebrew not found. Install manually:

1. Download the installer:
   curl -O https://cdn.teleport.dev/teleport-v18.6.6.pkg

2. Install (requires sudo):
   sudo installer -pkg teleport-v18.6.6.pkg -target /

3. Verify:
   tsh version

Or install Homebrew first:
   /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
   brew install teleport
"""

    elif system == "linux":
        # Linux - try the official install script
        result = ["Installing Teleport via official script..."]
        success, output = _run_install(
            ["bash", "-c", "curl -fsSL https://goteleport.com/static/install.sh | bash -s 18.6.6"],
            "Teleport"
        )

        if success:
            installed, version = _check_command("tsh")
            if installed:
                result.append(f"✓ Teleport installed successfully!")
                result.append(f"  Version: {version}")
            else:
                result.append("✓ Install script completed. You may need to add tsh to your PATH.")
            return "\n".join(result)
        else:
            result.append(f"✗ Install failed: {output}")
            result.append("")
            result.append("Try manual installation:")
            result.append("  curl -O https://cdn.teleport.dev/teleport-v18.6.6-linux-amd64-bin.tar.gz")
            result.append("  tar -xzf teleport-v18.6.6-linux-amd64-bin.tar.gz")
            result.append("  sudo mv teleport/tsh /usr/local/bin/")
            return "\n".join(result)

    else:
        return f"""Teleport CLI (tsh) Installation

Your platform: {system} ({arch})

Please download from: https://goteleport.com/download/
"""


@mcp.tool()
def setup_install_kubectl() -> str:
    """
    Install kubectl for Kubernetes/Cube management.

    Attempts to install via Homebrew (macOS) or direct download.
    """
    system, arch = _get_platform()

    # Check if already installed
    installed, version = _check_command("kubectl")
    if installed:
        return f"kubectl is already installed.\n\nVersion: {version}"

    if system == "darwin":
        if _has_brew():
            result = ["Installing kubectl via Homebrew..."]
            success, output = _run_install(["brew", "install", "kubectl"], "kubectl")

            if success:
                installed, version = _check_command("kubectl")
                result.append(f"✓ kubectl installed successfully!")
                result.append(f"  Version: {version}")
                return "\n".join(result)
            else:
                result.append(f"✗ Homebrew install failed: {output}")
                return "\n".join(result)
        else:
            return """kubectl Installation - macOS

Homebrew not found. Install manually:

   curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/darwin/arm64/kubectl"
   chmod +x kubectl
   sudo mv kubectl /usr/local/bin/
   kubectl version --client
"""

    elif system == "linux":
        # Try direct download for Linux
        result = ["Installing kubectl..."]

        # Download kubectl
        download_cmd = [
            "bash", "-c",
            'curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && chmod +x kubectl && sudo mv kubectl /usr/local/bin/'
        ]
        success, output = _run_install(download_cmd, "kubectl")

        if success:
            installed, version = _check_command("kubectl")
            if installed:
                result.append(f"✓ kubectl installed successfully!")
                result.append(f"  Version: {version}")
            else:
                result.append("✓ Download completed. You may need to add kubectl to your PATH.")
            return "\n".join(result)
        else:
            result.append(f"✗ Install failed: {output}")
            result.append("")
            result.append("Try manual installation:")
            result.append('  curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"')
            result.append("  chmod +x kubectl")
            result.append("  sudo mv kubectl /usr/local/bin/")
            return "\n".join(result)

    else:
        return f"""kubectl Installation

Your platform: {system} ({arch})

Download from: https://kubernetes.io/docs/tasks/tools/
"""


@mcp.tool()
def setup_install_helm() -> str:
    """
    Install Helm for chart packaging and deployment.

    Attempts to install via Homebrew (macOS) or official install script.
    """
    system, arch = _get_platform()

    # Check if already installed
    installed, version = _check_command("helm")
    if installed:
        return f"Helm is already installed.\n\nVersion: {version}"

    if system == "darwin":
        if _has_brew():
            result = ["Installing Helm via Homebrew..."]
            success, output = _run_install(["brew", "install", "helm"], "Helm")

            if success:
                installed, version = _check_command("helm")
                result.append(f"✓ Helm installed successfully!")
                result.append(f"  Version: {version}")
                return "\n".join(result)
            else:
                result.append(f"✗ Homebrew install failed: {output}")
                return "\n".join(result)
        else:
            # Try the official install script
            result = ["Installing Helm via official script..."]
            success, output = _run_install(
                ["bash", "-c", "curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash"],
                "Helm"
            )
            if success:
                installed, version = _check_command("helm")
                result.append(f"✓ Helm installed successfully!")
                if installed:
                    result.append(f"  Version: {version}")
                return "\n".join(result)
            else:
                result.append(f"✗ Install failed: {output}")
                return "\n".join(result)

    elif system == "linux":
        # Try the official install script
        result = ["Installing Helm via official script..."]
        success, output = _run_install(
            ["bash", "-c", "curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash"],
            "Helm"
        )

        if success:
            installed, version = _check_command("helm")
            result.append(f"✓ Helm installed successfully!")
            if installed:
                result.append(f"  Version: {version}")
            return "\n".join(result)
        else:
            result.append(f"✗ Install failed: {output}")
            result.append("")
            result.append("Try manual installation:")
            result.append("  curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash")
            return "\n".join(result)

    else:
        return f"""Helm Installation

Your platform: {system} ({arch})

Download from: https://helm.sh/docs/intro/install/
"""


@mcp.tool()
def setup_install_docker() -> str:
    """
    Install Docker for building container images.

    On macOS, provides instructions for Docker Desktop.
    On Linux, attempts to install via the official script.
    """
    system, arch = _get_platform()

    # Check if already installed
    installed, version = _check_command("docker")
    if installed:
        return f"Docker is already installed.\n\nVersion: {version}"

    if system == "darwin":
        # macOS - Docker Desktop is required (can't be automated)
        # But we can try brew install --cask docker
        if _has_brew():
            result = ["Installing Docker Desktop via Homebrew..."]
            success, output = _run_install(["brew", "install", "--cask", "docker"], "Docker")

            if success:
                result.append("✓ Docker Desktop installed!")
                result.append("")
                result.append("IMPORTANT: You need to launch Docker Desktop from Applications")
                result.append("to complete setup. The docker CLI won't work until Docker Desktop is running.")
                return "\n".join(result)
            else:
                result.append(f"✗ Homebrew install failed: {output}")
                result.append("")
                result.append("Install manually from: https://www.docker.com/products/docker-desktop/")
                return "\n".join(result)
        else:
            return """Docker Installation - macOS

Docker Desktop is required on macOS.

1. Download Docker Desktop:
   https://www.docker.com/products/docker-desktop/

2. Install the .dmg file

3. Launch Docker Desktop from Applications

4. Verify installation:
   docker --version
"""

    elif system == "linux":
        # Linux - try the official install script
        result = ["Installing Docker via official script..."]
        success, output = _run_install(
            ["bash", "-c", "curl -fsSL https://get.docker.com | sh"],
            "Docker"
        )

        if success:
            installed, version = _check_command("docker")
            result.append("✓ Docker installed successfully!")
            if installed:
                result.append(f"  Version: {version}")
            result.append("")
            result.append("Note: You may need to run 'sudo usermod -aG docker $USER'")
            result.append("and log out/in for docker to work without sudo.")
            return "\n".join(result)
        else:
            result.append(f"✗ Install failed: {output}")
            result.append("")
            result.append("Try manual installation:")
            result.append("  curl -fsSL https://get.docker.com | sh")
            return "\n".join(result)

    else:
        return f"""Docker Installation

Your platform: {system} ({arch})

Download Docker Desktop from:
https://www.docker.com/products/docker-desktop/
"""


@mcp.tool()
def setup_install_apollo() -> str:
    """
    Provide instructions for installing Apollo CLI.

    Apollo CLI is a Palantir tool for publishing to Apollo.
    It must be downloaded from your organization's Apollo instance.
    """
    system, arch = _get_platform()

    # Check if already installed
    installed, version = _check_command("apollo-cli")
    if installed:
        return f"Apollo CLI is already installed.\n\nLocation: {version}\n\nRun 'apollo-cli --help' for usage."

    return f"""Apollo CLI Installation

Apollo CLI is a proprietary tool from Palantir and must be downloaded
from your organization's Apollo instance.

Platform: {system} ({arch})

Installation Steps:

1. Download the apollo-cli binary for your platform from your
   Apollo instance (ask your Apollo administrator for access)

2. Make it executable (macOS/Linux):
   chmod +x apollo-cli

3. Move to a directory in your PATH:
   # macOS/Linux
   mkdir -p ~/.local/bin
   mv apollo-cli ~/.local/bin/

   # Add to PATH if not already (add to ~/.bashrc or ~/.zshrc):
   export PATH="$HOME/.local/bin:$PATH"

4. Verify installation:
   apollo-cli --help

Contact your Apollo administrator if you need help obtaining
the apollo-cli binary.
"""


@mcp.tool()
def setup_configure_credentials() -> str:
    """
    Help configure Apollo credentials (APOLLO_CLIENT and APOLLO_SECRET).

    These are required for pushing to Apollo Container Registry
    and for all Apollo GraphQL operations (create_environment,
    replicate_environment, install_entity, etc.).
    """
    # Check if already configured
    client_id, client_secret = _get_apollo_credentials()
    if client_id and client_secret:
        source = "environment variables" if os.environ.get("APOLLO_CLIENT") else "apollo-cli profile"
        return f"Apollo credentials are already configured (from {source}).\n\nClient ID: {client_id[:8]}..."

    return """Apollo credentials are not configured.

Choose one of these options:

**Option 1: apollo-cli configure (recommended)**

  apollo-cli configure

This stores credentials in your apollo-cli profile. The MCP server reads them automatically.

**Option 2: Environment variables**

Add to ~/.bashrc or ~/.zshrc:

  export APOLLO_CLIENT="<your-client-id>"
  export APOLLO_SECRET="<your-client-secret>"

Then restart your terminal or run:

  source ~/.bashrc

To generate a token, go to: https://edgescaleai.palantirapollo.com/multipass/app/"""


# ============================================================================
# Cube Management Tools
# ============================================================================

@mcp.tool()
def cube_login(cluster: str, proxy: str = DEFAULT_PROXY) -> str:
    """
    Login to a Cube cluster via Teleport (proxy: edgescaleai.teleport.sh).
    This will authenticate you via SSO and set up kubectl access.

    Args:
        cluster: Cube cluster name to connect to (e.g., staging-int, staging-pre-prod, ironmountain-v4)
        proxy: Teleport proxy URL. Defaults to edgescaleai.teleport.sh
    """
    try:
        # Check if already logged into Teleport
        status_result = subprocess.run(
            ["tsh", "status"],
            capture_output=True,
            text=True,
            timeout=10
        )

        teleport_logged_in = (
            status_result.returncode == 0 and
            "EXPIRED" not in status_result.stdout and
            proxy in status_result.stdout
        )

        if not teleport_logged_in:
            # Guide user to login - show both options
            return f"""You need to login to Teleport first.

Choose one of these options:

**Option 1: Okta SSO** (opens browser)
  tsh login --proxy={proxy} --auth=okta

**Option 2: Username/Password**
  tsh login --proxy={proxy} --user=<your-username>

Run the command in your terminal, then ask me to connect to '{cluster}' again."""

        # Already logged in - connect to the Cube cluster
        kube_result = subprocess.run(
            ["tsh", "kube", "login", cluster],
            capture_output=True,
            text=True,
            timeout=30
        )

        if kube_result.returncode != 0:
            return f"Failed to connect to Cube '{cluster}':\n{kube_result.stderr}\n\nUse cube_list to see available Cubes."

        return f"""✓ Connected to Cube: {cluster}

You can now use:
- cube_status: Check node health
- cube_list: See other available Cubes"""

    except subprocess.TimeoutExpired:
        return "Connection timed out. Check your network."
    except FileNotFoundError:
        return "Teleport client (tsh) not found.\n\nUse setup_install_teleport to install it."


@mcp.tool()
def cube_login_password(cluster: str, username: str, proxy: str = DEFAULT_PROXY) -> str:
    """
    Login to a Cube cluster via Teleport using username/password.

    Args:
        cluster: Cube cluster name to connect to (e.g., staging-int, staging-pre-prod, ironmountain-v4)
        username: Your Teleport username
        proxy: Teleport proxy URL. Defaults to edgescaleai.teleport.sh
    """
    return f"""Run this command in your terminal:

  tsh login --proxy={proxy} --user={username} {cluster}

You will be prompted for your password.

Once complete, ask me to connect to '{cluster}' again."""


@mcp.tool()
def cube_list() -> str:
    """
    List all available Cube clusters accessible via Teleport.
    Requires being logged into Teleport first.
    """
    try:
        # Check if logged in first
        status_result = subprocess.run(
            ["tsh", "status"],
            capture_output=True,
            text=True,
            timeout=10
        )

        if status_result.returncode != 0 or "EXPIRED" in status_result.stdout:
            return f"Not logged into Teleport or session expired.\n\nRun cube_login first to authenticate, or manually:\n  tsh login --proxy={DEFAULT_PROXY}"

        # List available Kubernetes clusters
        list_result = subprocess.run(
            ["tsh", "kube", "ls"],
            capture_output=True,
            text=True,
            timeout=30
        )

        if list_result.returncode != 0:
            return f"Failed to list Cubes:\n{list_result.stderr}"

        return f"Available Cubes:\n\n{list_result.stdout}\n\nUse cube_login with a cluster name to connect."

    except subprocess.TimeoutExpired:
        return "Command timed out. Check your network connection."
    except FileNotFoundError:
        return "Teleport client (tsh) not found.\n\nInstall it from: https://goteleport.com/download/"


@mcp.tool()
def cube_status() -> str:
    """
    Get the status of the connected Cube node.
    Shows node conditions, capacity, and resource usage.
    """
    try:
        # First, get the node name
        get_nodes = subprocess.run(
            ["kubectl", "--insecure-skip-tls-verify", "get", "nodes", "-o", "jsonpath={.items[0].metadata.name}"],
            capture_output=True,
            text=True,
            timeout=30
        )

        if get_nodes.returncode != 0:
            return f"Failed to get node. Are you connected to a Cube?\n\nError: {get_nodes.stderr}\n\nUse cube_login first to connect."

        node_name = get_nodes.stdout.strip()
        if not node_name:
            return "No nodes found. The Cube may not be running or you may not be connected."

        # Get detailed node status
        describe_result = subprocess.run(
            ["kubectl", "--insecure-skip-tls-verify", "describe", "node", node_name],
            capture_output=True,
            text=True,
            timeout=30
        )

        if describe_result.returncode != 0:
            return f"Failed to describe node:\n{describe_result.stderr}"

        return f"Cube Node Status ({node_name}):\n\n{describe_result.stdout}"

    except subprocess.TimeoutExpired:
        return "Command timed out. The Cube may be unresponsive."
    except FileNotFoundError:
        return "kubectl not found. Ensure it's installed and in your PATH."


# ============================================================================
# App Proxy Tools - Access Teleport apps locally
# ============================================================================

@mcp.tool()
def app_list(company: str = None) -> str:
    """
    List available Teleport apps.

    Shows apps accessible via Teleport including HTTP and TCP apps
    like databases, MQTT brokers, web UIs, etc.

    Args:
        company: Optional filter by company label (e.g., "edgescaleai", "lear", "conagra")
    """
    try:
        # Check if logged in first
        status_result = subprocess.run(
            ["tsh", "status"],
            capture_output=True,
            text=True,
            timeout=10
        )

        if status_result.returncode != 0 or "EXPIRED" in status_result.stdout:
            return f"""Not logged into Teleport or session expired.

Run cube_login first to authenticate, or manually:
  tsh login --proxy={DEFAULT_PROXY} --auth=okta"""

        # List apps in JSON format
        list_result = subprocess.run(
            ["tsh", "apps", "ls", "--format=json"],
            capture_output=True,
            text=True,
            timeout=30
        )

        if list_result.returncode != 0:
            return f"Failed to list apps:\n{list_result.stderr}"

        apps = json.loads(list_result.stdout)

        # Filter by company if specified
        if company:
            apps = [
                app for app in apps
                if app.get("metadata", {}).get("labels", {}).get("company", "").lower() == company.lower()
            ]

        if not apps:
            if company:
                return f"No apps found for company '{company}'."
            return "No apps found."

        # Format output
        lines = ["Available Apps:", "=" * 60]

        # Group by company
        by_company = {}
        for app in apps:
            labels = app.get("metadata", {}).get("labels", {})
            comp = labels.get("company", "unknown")
            if comp not in by_company:
                by_company[comp] = []
            by_company[comp].append(app)

        for comp in sorted(by_company.keys()):
            lines.append(f"\n[{comp}]")
            for app in sorted(by_company[comp], key=lambda x: x["metadata"]["name"]):
                name = app["metadata"]["name"]
                spec = app.get("spec", {})
                public_addr = spec.get("public_addr", "")
                uri = spec.get("uri", "")

                # Determine type from URI
                app_type = "TCP" if uri.startswith("tcp://") else "HTTP"

                lines.append(f"  {name}")
                lines.append(f"    Type: {app_type}")
                if public_addr:
                    lines.append(f"    URL:  https://{public_addr}")

        lines.append("")
        lines.append("Use app_proxy to start a local proxy for any app.")

        return "\n".join(lines)

    except subprocess.TimeoutExpired:
        return "Command timed out. Check your network connection."
    except FileNotFoundError:
        return "Teleport client (tsh) not found.\n\nUse setup_install_teleport to install it."
    except json.JSONDecodeError as e:
        return f"Failed to parse app list: {e}"


@mcp.tool()
def app_login(app: str) -> str:
    """
    Login to a Teleport app to get a certificate.

    This retrieves a short-lived certificate for the app.
    Required before starting a proxy for some apps.

    Args:
        app: App name (from app_list)
    """
    try:
        # Check if logged into Teleport
        status_result = subprocess.run(
            ["tsh", "status"],
            capture_output=True,
            text=True,
            timeout=10
        )

        if status_result.returncode != 0 or "EXPIRED" in status_result.stdout:
            return f"""Not logged into Teleport or session expired.

Run cube_login first to authenticate, or manually:
  tsh login --proxy={DEFAULT_PROXY} --auth=okta"""

        # Login to the app
        login_result = subprocess.run(
            ["tsh", "app", "login", app],
            capture_output=True,
            text=True,
            timeout=30
        )

        if login_result.returncode != 0:
            return f"Failed to login to app '{app}':\n{login_result.stderr}\n\nUse app_list to see available apps."

        return f"""✓ Logged into app: {app}

You can now:
- Use app_proxy to start a local proxy
- Access HTTP apps directly via their public URL"""

    except subprocess.TimeoutExpired:
        return "Command timed out. Check your network connection."
    except FileNotFoundError:
        return "Teleport client (tsh) not found.\n\nUse setup_install_teleport to install it."


@mcp.tool()
def app_proxy(app: str, port: int = None) -> str:
    """
    Start a local proxy for a Teleport app.

    This creates a local endpoint that proxies to the remote app.
    Essential for TCP apps like MQTT/Mosquitto, databases, etc.

    For HTTP apps, you can also access them directly via their public URL
    after running app_login.

    Args:
        app: App name (from app_list)
        port: Local port to listen on (auto-assigned if not specified)
    """
    global _running_proxies

    try:
        # Check if already proxying this app
        if app in _running_proxies:
            proc = _running_proxies[app]
            if proc.poll() is None:  # Still running
                return f"Proxy for '{app}' is already running.\n\nUse app_proxy_stop to stop it first."
            else:
                # Process died, clean up
                del _running_proxies[app]

        # Check if logged into Teleport
        status_result = subprocess.run(
            ["tsh", "status"],
            capture_output=True,
            text=True,
            timeout=10
        )

        if status_result.returncode != 0 or "EXPIRED" in status_result.stdout:
            return f"""Not logged into Teleport or session expired.

Run cube_login first to authenticate, or manually:
  tsh login --proxy={DEFAULT_PROXY} --auth=okta"""

        # Login to the app first (required for proxy)
        login_result = subprocess.run(
            ["tsh", "app", "login", app],
            capture_output=True,
            text=True,
            timeout=30
        )

        if login_result.returncode != 0:
            return f"Failed to login to app '{app}':\n{login_result.stderr}\n\nUse app_list to see available apps."

        # Build proxy command
        cmd = ["tsh", "proxy", "app", app]
        if port:
            cmd.extend(["--port", str(port)])

        # Start proxy in background
        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True
        )

        # Wait briefly for startup and capture output
        import time
        time.sleep(2)

        # Check if it started successfully
        if proc.poll() is not None:
            # Process exited - get error
            _, stderr = proc.communicate(timeout=5)
            return f"Failed to start proxy for '{app}':\n{stderr}"

        # Store the running process
        _running_proxies[app] = proc

        # Try to get the local address from stderr (tsh prints it there)
        # Format: "Proxying connections to <app> on <host>:<port>"
        import select
        local_addr = None

        # Non-blocking read of stderr
        if proc.stderr:
            import fcntl
            fd = proc.stderr.fileno()
            fl = fcntl.fcntl(fd, fcntl.F_GETFL)
            fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
            try:
                output = proc.stderr.read()
                if output:
                    # Parse the address
                    match = re.search(r'(\d+\.\d+\.\d+\.\d+:\d+|localhost:\d+|127\.0\.0\.1:\d+)', output)
                    if match:
                        local_addr = match.group(1)
            except:
                pass

        # Build response
        result = [f"✓ Proxy started for: {app}"]

        if local_addr:
            result.append(f"\nLocal address: {local_addr}")
        else:
            result.append(f"\nLocal address: localhost:{port or '(check tsh output)'}")

        # Add usage hints based on app name
        app_lower = app.lower()
        if "mosquitto" in app_lower or "mqtt" in app_lower:
            result.append(f"""
MQTT Connection:
  Host: localhost
  Port: {port or '(see above)'}

Example (mosquitto_sub):
  mosquitto_sub -h localhost -p {port or '<port>'} -t '#'""")
        elif "rabbit" in app_lower:
            result.append(f"""
RabbitMQ Management UI:
  http://localhost:{port or '<port>'}""")
        elif "sql" in app_lower or "trino" in app_lower:
            result.append(f"""
Database connection:
  Host: localhost
  Port: {port or '<port>'}""")

        result.append("\nUse app_proxy_stop to stop the proxy when done.")

        return "\n".join(result)

    except subprocess.TimeoutExpired:
        return "Command timed out. Check your network connection."
    except FileNotFoundError:
        return "Teleport client (tsh) not found.\n\nUse setup_install_teleport to install it."
    except Exception as e:
        return f"Error starting proxy: {e}"


@mcp.tool()
def app_proxy_stop(app: str = None) -> str:
    """
    Stop a running app proxy.

    Args:
        app: App name to stop. If not specified, stops all proxies.
    """
    global _running_proxies

    if not _running_proxies:
        return "No proxies are currently running."

    stopped = []
    errors = []

    if app:
        # Stop specific app
        if app not in _running_proxies:
            return f"No proxy running for '{app}'.\n\nRunning proxies: {', '.join(_running_proxies.keys())}"

        proc = _running_proxies[app]
        try:
            proc.terminate()
            proc.wait(timeout=5)
            stopped.append(app)
        except subprocess.TimeoutExpired:
            proc.kill()
            stopped.append(f"{app} (killed)")
        except Exception as e:
            errors.append(f"{app}: {e}")
        finally:
            del _running_proxies[app]
    else:
        # Stop all
        for app_name, proc in list(_running_proxies.items()):
            try:
                proc.terminate()
                proc.wait(timeout=5)
                stopped.append(app_name)
            except subprocess.TimeoutExpired:
                proc.kill()
                stopped.append(f"{app_name} (killed)")
            except Exception as e:
                errors.append(f"{app_name}: {e}")
        _running_proxies.clear()

    result = []
    if stopped:
        result.append(f"✓ Stopped proxies: {', '.join(stopped)}")
    if errors:
        result.append(f"✗ Errors: {', '.join(errors)}")

    return "\n".join(result) or "No proxies were stopped."


@mcp.tool()
def app_proxy_status() -> str:
    """
    Show status of running app proxies.
    """
    global _running_proxies

    if not _running_proxies:
        return "No proxies are currently running.\n\nUse app_proxy to start one."

    lines = ["Running App Proxies:", "=" * 40]

    for app_name, proc in list(_running_proxies.items()):
        if proc.poll() is None:
            lines.append(f"  ✓ {app_name} (PID: {proc.pid})")
        else:
            lines.append(f"  ✗ {app_name} (stopped)")
            del _running_proxies[app_name]

    lines.append("")
    lines.append("Use app_proxy_stop to stop proxies.")

    return "\n".join(lines)


# ============================================================================
# ACR (Apollo Container Registry) Tools
# ============================================================================

@mcp.tool()
def acr_get_token() -> str:
    """
    Get an OAuth2 access token from Apollo Container Registry.

    Requires Apollo credentials via either:
    - apollo-cli profile (run 'apollo-cli configure')
    - APOLLO_CLIENT and APOLLO_SECRET environment variables

    Useful for debugging or manual operations.
    """
    token, error = _get_acr_token()
    if error:
        return error

    return f"Successfully retrieved ACR token.\n\nToken (first 20 chars): {token[:20]}...\n\nUse acr_login to authenticate Docker and Helm."


@mcp.tool()
def acr_login() -> str:
    """
    Login to Apollo Container Registry for both Docker and Helm.

    Requires Apollo credentials via either:
    - apollo-cli profile (run 'apollo-cli configure')
    - APOLLO_CLIENT and APOLLO_SECRET environment variables

    This authenticates you to push Docker images and Helm charts to ACR.
    """
    token, error = _get_acr_token()
    if error:
        return error

    results = []

    # Docker login
    try:
        docker_result = subprocess.run(
            ["docker", "login", ACR_REGISTRY, "--username", "apollo", "--password-stdin"],
            input=token,
            capture_output=True,
            text=True,
            timeout=30
        )
        if docker_result.returncode == 0:
            results.append(f"✓ Docker logged in to {ACR_REGISTRY}")
        else:
            results.append(f"✗ Docker login failed: {docker_result.stderr}")
    except FileNotFoundError:
        results.append("✗ Docker not found (skipping)")
    except Exception as e:
        results.append(f"✗ Docker login error: {e}")

    # Helm registry login
    try:
        helm_result = subprocess.run(
            ["helm", "registry", "login", ACR_REGISTRY, "--username", "apollo", "--password-stdin"],
            input=token,
            capture_output=True,
            text=True,
            timeout=30
        )
        if helm_result.returncode == 0:
            results.append(f"✓ Helm logged in to {ACR_REGISTRY}")
        else:
            results.append(f"✗ Helm login failed: {helm_result.stderr}")
    except FileNotFoundError:
        results.append("✗ Helm not found")
    except Exception as e:
        results.append(f"✗ Helm login error: {e}")

    return "ACR Login Results:\n\n" + "\n".join(results)


@mcp.tool()
def build_and_publish_to_apollo(
    chart_path: str = None,
    version: str = None,
    bump: str = "patch",
    app_version: str = None,
    skip_docker: bool = False,
    dry_run: bool = False
) -> str:
    """
    Build and publish an app to Apollo Container Registry.

    This builds your app and publishes it to Apollo, making it available
    for deployment to Cube environments. It does NOT deploy to a Cube -
    that's a separate step done from Apollo.

    Pipeline:
    1. Login to ACR (Docker + Helm registries)
    2. Build and push Docker image (if Dockerfile found, multi-arch)
    3. Bump Chart.yaml version
    4. Package Helm chart
    5. Push Helm chart to ACR (OCI registry)
    6. Publish manifest to Apollo (makes it available for deployment)

    Args:
        chart_path: Path to chart directory or Chart.yaml (auto-detected if not specified)
        version: Exact version to use (e.g., "1.0.0"). If not specified, bumps current version.
        bump: Version bump type: "patch" (default), "minor", or "major". Ignored if version is specified.
        app_version: New appVersion for Docker image tag. If not specified, uses chart version.
        skip_docker: Skip Docker build/push even if Dockerfile exists.
        dry_run: Show what would be done without actually doing it.
    """
    steps = []
    errors = []

    # Step 1: Find and parse Chart.yaml
    chart_yaml, error = _find_chart_yaml(chart_path)
    if error:
        return f"❌ {error}"

    chart_info, error = _parse_chart_yaml(chart_yaml)
    if error:
        return f"❌ {error}"

    chart_dir = chart_info["dir"]
    chart_name = chart_info["name"]
    current_version = chart_info["version"]

    # Calculate new version
    if version:
        new_version = version
    else:
        new_version = _bump_version(current_version, bump)

    # Calculate app version (for Docker tag)
    new_app_version = app_version or new_version

    # Check for Dockerfile
    dockerfile_path = os.path.join(chart_dir, "Dockerfile")
    if not os.path.isfile(dockerfile_path):
        dockerfile_path = os.path.join(os.path.dirname(chart_dir), "Dockerfile")
    has_dockerfile = os.path.isfile(dockerfile_path) and not skip_docker

    # Build summary
    summary = f"""
📦 Helm Deploy Summary
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Chart:        {chart_name}
Path:         {chart_yaml}
Version:      {current_version} → {new_version}
App Version:  {chart_info.get('appVersion', 'N/A')} → {new_app_version}
Docker Build: {'Yes' if has_dockerfile else 'No (no Dockerfile)'}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Pipeline:
1. Login to ACR
2. {'Build & push Docker image' if has_dockerfile else '(skip Docker - no Dockerfile)'}
3. Update Chart.yaml version
4. Package Helm chart
5. Push to ACR: {ACR_HELM_PREFIX}/{chart_name}
6. Publish to Apollo
"""

    if dry_run:
        return summary + "\n🔍 DRY RUN - No changes made."

    steps.append(summary)

    # Step 2: Login to ACR
    token, error = _get_acr_token()
    if error:
        return f"❌ ACR Login Failed:\n{error}"

    # Docker login
    try:
        docker_login = subprocess.run(
            ["docker", "login", ACR_REGISTRY, "--username", "apollo", "--password-stdin"],
            input=token,
            capture_output=True,
            text=True,
            timeout=30
        )
        if docker_login.returncode == 0:
            steps.append("✓ Docker logged in")
        else:
            steps.append(f"⚠ Docker login failed (continuing): {docker_login.stderr.strip()}")
    except Exception as e:
        steps.append(f"⚠ Docker login skipped: {e}")

    # Helm login
    try:
        helm_login = subprocess.run(
            ["helm", "registry", "login", ACR_REGISTRY, "--username", "apollo", "--password-stdin"],
            input=token,
            capture_output=True,
            text=True,
            timeout=30
        )
        if helm_login.returncode == 0:
            steps.append("✓ Helm logged in")
        else:
            return f"❌ Helm login failed: {helm_login.stderr}"
    except FileNotFoundError:
        return "❌ Helm not found. Please install Helm."

    # Step 3: Build and push Docker image (if applicable)
    if has_dockerfile:
        docker_tag = f"{ACR_DOCKER_PREFIX}/{chart_name}:{new_app_version}"
        steps.append(f"Building Docker image: {docker_tag}")

        try:
            # Build with buildx for multi-arch
            build_result = subprocess.run(
                [
                    "docker", "buildx", "build",
                    "--platform", "linux/amd64,linux/arm64",
                    "--tag", docker_tag,
                    "--output", "type=image,push=true",
                    os.path.dirname(dockerfile_path) or "."
                ],
                capture_output=True,
                text=True,
                timeout=600  # 10 min timeout for builds
            )
            if build_result.returncode == 0:
                steps.append(f"✓ Docker image pushed: {docker_tag}")
            else:
                errors.append(f"Docker build failed: {build_result.stderr}")
                steps.append(f"✗ Docker build failed (continuing with Helm only)")
        except subprocess.TimeoutExpired:
            errors.append("Docker build timed out")
            steps.append("✗ Docker build timed out (continuing with Helm only)")
        except Exception as e:
            errors.append(f"Docker error: {e}")
            steps.append(f"✗ Docker error: {e} (continuing with Helm only)")

    # Step 4: Update Chart.yaml version
    success, error = _update_chart_version(chart_yaml, new_version, new_app_version if has_dockerfile else None)
    if not success:
        return f"❌ Failed to update Chart.yaml: {error}"
    steps.append(f"✓ Updated Chart.yaml to version {new_version}")

    # Step 5: Package Helm chart
    try:
        # Run helm dependency update
        dep_result = subprocess.run(
            ["helm", "dependency", "update"],
            cwd=chart_dir,
            capture_output=True,
            text=True,
            timeout=120
        )
        if dep_result.returncode != 0:
            steps.append(f"⚠ helm dependency update warning: {dep_result.stderr.strip()}")

        # Package
        package_result = subprocess.run(
            ["helm", "package", "."],
            cwd=chart_dir,
            capture_output=True,
            text=True,
            timeout=60
        )
        if package_result.returncode != 0:
            return f"❌ Helm package failed: {package_result.stderr}"

        tgz_file = f"{chart_name}-{new_version}.tgz"
        tgz_path = os.path.join(chart_dir, tgz_file)
        steps.append(f"✓ Packaged: {tgz_file}")
    except Exception as e:
        return f"❌ Helm package error: {e}"

    # Step 6: Push to ACR
    try:
        push_result = subprocess.run(
            ["helm", "push", tgz_path, ACR_HELM_PREFIX],
            capture_output=True,
            text=True,
            timeout=120
        )
        if push_result.returncode != 0:
            return f"❌ Helm push failed: {push_result.stderr}"
        steps.append(f"✓ Pushed to {ACR_HELM_PREFIX}/{chart_name}")

        # Clean up .tgz file
        try:
            os.remove(tgz_path)
        except:
            pass
    except Exception as e:
        return f"❌ Helm push error: {e}"

    # Step 7: Publish to Apollo via apollo-cli
    client_id, client_secret = _get_apollo_credentials()
    oci_url = f"{ACR_HELM_PREFIX}/{chart_name}"
    maven_coordinate = f"{APOLLO_GROUP_ID}:{chart_name}:{new_version}"
    manifest_path = "/tmp/manifest.yml"

    try:
        # Generate manifest.yml using apollo-cli
        init_cmd = [
            "apollo-cli", "product-release", "helm-chart", "init",
            "--maven-coordinate", maven_coordinate,
            "--name", oci_url,
            "--pass-credentials",
            "--repository-url", oci_url,
            "--output-dir", "/tmp/",
            "--username", "apollo",
            "--password", token,
            "--version", new_version,
            "--apollo-client-id", client_id,
            "--apollo-client-secret", client_secret,
            "--apollo-url", APOLLO_URL,
        ]

        init_result = subprocess.run(
            init_cmd,
            capture_output=True,
            text=True,
            timeout=60
        )

        if init_result.returncode != 0:
            errors.append(f"apollo-cli init failed: {init_result.stderr}")
            steps.append(f"⚠ Apollo manifest generation failed: {init_result.stderr.strip()}")
        else:
            steps.append("✓ Generated Apollo manifest")

            # Publish manifest to Apollo
            publish_cmd = [
                "apollo-cli", "publish", "manifest",
                f"--manifest-file={manifest_path}",
                "--apollo-client-id", client_id,
                "--apollo-client-secret", client_secret,
                "--apollo-url", APOLLO_URL,
            ]

            publish_result = subprocess.run(
                publish_cmd,
                capture_output=True,
                text=True,
                timeout=60
            )

            if publish_result.returncode != 0:
                errors.append(f"apollo-cli publish failed: {publish_result.stderr}")
                steps.append(f"⚠ Apollo publish failed: {publish_result.stderr.strip()}")
            else:
                steps.append("✓ Published to Apollo")

    except FileNotFoundError:
        errors.append("apollo-cli not found")
        steps.append("⚠ apollo-cli not found - skipping Apollo publish. Install with: pip install apollo-cli")
    except Exception as e:
        errors.append(f"Apollo error: {e}")
        steps.append(f"⚠ Apollo publish error: {e}")

    # Final summary
    result = "\n".join(steps)
    result += f"\n\n{'🎉' if not errors else '⚠️'} Deployment {'complete' if not errors else 'completed with warnings'}!"
    result += f"\n\nChart: {chart_name}:{new_version}"
    result += f"\nHelm:  {ACR_HELM_PREFIX}/{chart_name}"

    if errors:
        result += f"\n\nWarnings:\n" + "\n".join(f"  - {e}" for e in errors)

    return result


# ============================================================================
# Apollo GraphQL Tools
# ============================================================================

def _apollo_graphql_query(query: str, variables: dict = None) -> tuple[dict, str]:
    """
    Execute a GraphQL query against Apollo. Returns (data, error_message).
    Reuses _get_acr_token() for authentication.
    """
    token, error = _get_acr_token()
    if error:
        return None, error

    payload = {"query": query}
    if variables:
        payload["variables"] = variables

    try:
        result = subprocess.run(
            [
                "curl", "-s", "--max-time", "60",
                APOLLO_GRAPHQL_URL,
                "-H", "Content-Type: application/json",
                "-H", f"Authorization: Bearer {token}",
                "-H", f"Fetch-User-Agent: {APOLLO_GRAPHQL_USER_AGENT}",
                "-d", json.dumps(payload)
            ],
            capture_output=True,
            text=True,
            timeout=90
        )

        if result.returncode != 0:
            return None, f"GraphQL request failed: {result.stderr}"

        response = json.loads(result.stdout)

        if "errors" in response:
            error_msgs = []
            for e in response["errors"]:
                msg = e.get("message", str(e))
                svc_err = (e.get("extensions") or {}).get("serviceExceptionError", "")
                if svc_err:
                    msg = f"{msg} ({svc_err})"
                error_msgs.append(msg)
            return None, f"GraphQL errors: {'; '.join(error_msgs)}"

        return response.get("data", {}), None

    except subprocess.TimeoutExpired:
        return None, "GraphQL request timed out"
    except json.JSONDecodeError as e:
        return None, f"Invalid JSON response: {e}"
    except FileNotFoundError:
        return None, "curl not found. Please install curl."


def _format_duration(start_str: str, end_str: str = None) -> str:
    """Format duration between two ISO timestamps, or elapsed since start."""
    if not start_str:
        return ""
    try:
        from datetime import datetime, timezone
        start = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
        if end_str:
            end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
        else:
            end = datetime.now(timezone.utc)
        delta = end - start
        total_secs = int(delta.total_seconds())
        if total_secs < 60:
            return f"{total_secs}s"
        elif total_secs < 3600:
            return f"{total_secs // 60}m {total_secs % 60}s"
        else:
            hours = total_secs // 3600
            mins = (total_secs % 3600) // 60
            return f"{hours}h {mins}m"
    except Exception:
        return ""


_STATUS_ICONS = {
    "HEALTHY": "✓", "IN_SYNC": "✓", "ROLLED_OUT": "✓",
    "UNHEALTHY": "✗", "OUT_OF_SYNC": "✗",
    "ROLLING_OUT": "⟳", "IN_PROGRESS": "⟳",
    "UNKNOWN": "?", "NOT_APPLICABLE": "-"
}

_PLAN_STATUS_ICONS = {
    "SUCCESSFUL": "✓", "SUCCESS": "✓",
    "UNSUCCESSFUL": "✗", "FAILURE": "✗",
    "ACTIVE": "⟳", "PENDING_INTERRUPTION": "⏸",
    "ABORTED": "✗", "TIMEOUT": "✗", "INTERRUPTED": "✗",
}

_TASK_STATUS_ICONS = {
    "SUCCEEDED": "✓", "ACTIVE": "⟳", "FAILED": "✗",
    "ABANDONED": "—", "INTERRUPTED": "✗",
}


def _format_entity_health(entity: dict) -> list[str]:
    """Format a single entity's health data into readable lines."""
    lines = []
    name = entity.get("displayName") or entity.get("id", "unknown")
    entity_id = entity.get("id", "unknown")
    entity_name = entity.get("entityLocator", {}).get("entityName", "")
    product_id = entity.get("product", {}).get("productId", "")
    lifecycle = entity.get("lifecycleStatus", "")

    lines.append(f"  {name} ({entity_id})")
    if entity_name or product_id:
        lines.append(f"    Entity: {entity_name}  Product: {product_id}")
    if lifecycle:
        lines.append(f"    Lifecycle: {lifecycle}")

    # Reported status
    reported = entity.get("reportedStatus")
    if reported:
        version = reported.get("version", "unknown")
        rollout = reported.get("rolloutStatus", "unknown")
        config = reported.get("configStatus", "unknown")
        health = reported.get("healthStatus", "unknown")

        lines.append(f"    Version:  {version}")
        lines.append(f"    Health:   {_STATUS_ICONS.get(health, '?')} {health}  |  Rollout: {_STATUS_ICONS.get(rollout, '?')} {rollout}  |  Config: {_STATUS_ICONS.get(config, '?')} {config}")

        blockers = reported.get("upgradeBlockerTypes")
        if blockers:
            lines.append(f"    Blockers: {', '.join(blockers)}")
    else:
        lines.append("    Status: No reported status")

    # Plan status
    plan_status = entity.get("planStatus")
    if plan_status:
        typename = plan_status.get("__typename", "")
        status_label = typename.replace("ApolloEntityPlanStatus_", "") if typename else "Unknown"
        lines.append(f"    Plan:     {status_label}")

    # Recent plans (summary)
    plans_conn = entity.get("nonGenericPlans")
    if plans_conn:
        plans = plans_conn.get("plans", [])
        if plans:
            lines.append(f"    Recent Plans ({len(plans)}):")
            for plan in plans:
                ps = plan.get("status", {}) or {}
                status_type = ps.get("type", "unknown")
                icon = _PLAN_STATUS_ICONS.get(status_type, "?")

                # Determine plan description from state changes or tasks
                tasks = plan.get("tasks", [])
                # Try to figure out the plan type from tasks
                plan_desc = ""
                for t in tasks:
                    tdesc = (t.get("description") or {}).get("summary", "")
                    if "target state" in tdesc.lower() or "install" in tdesc.lower():
                        plan_desc = "Install"
                        break
                    elif "enforce" in tdesc.lower() or "config" in tdesc.lower():
                        plan_desc = "Enforce config"
                        break

                start = plan.get("startTime", "")
                end = plan.get("endTime")
                duration = _format_duration(start, end)
                start_display = start[:19].replace("T", " ") if start else ""

                result_summary = (ps.get("result") or {}).get("summary", "")
                reason_summary = (ps.get("reason") or {}).get("summary", "")
                detail = result_summary or reason_summary

                plan_line = f"      {icon} {status_type}"
                if plan_desc:
                    plan_line += f" ({plan_desc})"
                if duration:
                    plan_line += f" [{duration}]"
                if start_display:
                    plan_line += f" {start_display}"
                lines.append(plan_line)

                if detail:
                    # Truncate long error messages in summary view
                    if len(detail) > 120:
                        detail = detail[:117] + "..."
                    lines.append(f"        {detail}")

                # Show task summary
                for task in tasks:
                    desc = (task.get("description") or {}).get("summary", "") or (task.get("description") or {}).get("type", "")
                    ts = (task.get("status") or {})
                    ttype = ts.get("type", "") if isinstance(ts, dict) else str(ts)
                    ticon = _TASK_STATUS_ICONS.get(ttype, "?")
                    lines.append(f"        {ticon} {desc}: {ttype}")
        else:
            lines.append("    Recent Plans: (none)")

    lines.append(f"    (Use plan_details for full plan info with events and error logs)")

    return lines


def _resolve_environment(name_or_rid: str) -> tuple[dict, str]:
    """
    Resolve an environment name or RID to {id, rid, name}.
    If it looks like a RID (contains 'ri.'), use it directly.
    Otherwise, search by name (case-insensitive substring match).
    Returns (env_info, error_message).
    """
    if "ri." in name_or_rid:
        # Looks like a RID — query directly
        query = """
        query GetEnv($rid: RID!) {
            apollo {
                environment(rid: $rid) {
                    id
                    rid
                    name
                }
            }
        }
        """
        data, error = _apollo_graphql_query(query, {"rid": name_or_rid})
        if error:
            return None, error
        env = data.get("apollo", {}).get("environment")
        if not env:
            return None, f"Environment not found: {name_or_rid}"
        return env, None

    # Name search — fetch all environments and filter
    query = """
    query ListEnvs {
        apollo {
            environments(pageSize: 200) {
                environments {
                    id
                    rid
                    name
                }
                nextPageToken
            }
        }
    }
    """
    data, error = _apollo_graphql_query(query)
    if error:
        return None, error

    envs = data.get("apollo", {}).get("environments", {}).get("environments", [])
    search = name_or_rid.lower()

    # Exact match first
    for env in envs:
        if env.get("name", "").lower() == search:
            return env, None

    # Substring match
    matches = [e for e in envs if search in e.get("name", "").lower()]

    if len(matches) == 1:
        return matches[0], None
    elif len(matches) == 0:
        return None, f"No environment found matching '{name_or_rid}'. Use list_environments to see available names."
    else:
        names = [f"  - {m['name']}" for m in matches[:10]]
        return None, f"Multiple environments match '{name_or_rid}':\n" + "\n".join(names) + "\n\nPlease be more specific."


@mcp.tool()
def list_environments(search: str = None) -> str:
    """
    List Apollo environments with their IDs and RIDs. Optionally filter by name.

    Use this to find environment names before using create_environment,
    replicate_environment, entity_health, or install_entity.

    Args:
        search: Optional name substring to filter by (case-insensitive).
                Example: "pep" to find all pep-* environments.
    """
    query = """
    query ListEnvs {
        apollo {
            environments(pageSize: 200) {
                environments {
                    id
                    rid
                    name
                    namespaceId
                }
                nextPageToken
            }
        }
    }
    """
    data, error = _apollo_graphql_query(query)
    if error:
        return f"Error: {error}"

    envs = data.get("apollo", {}).get("environments", {}).get("environments", [])

    if search:
        search_lower = search.lower()
        envs = [e for e in envs if search_lower in e.get("name", "").lower()]

    if not envs:
        suffix = f' matching "{search}"' if search else ''
        return f"No environments found{suffix}."

    lines = [f"Apollo Environments ({len(envs)})", "=" * 60]
    for env in sorted(envs, key=lambda e: e.get("name", "")):
        name = env.get("name", "unknown")
        rid = env.get("rid", "")
        ns = env.get("namespaceId", "")
        lines.append(f"  {name}")
        lines.append(f"    RID: {rid}")
        if ns:
            lines.append(f"    Namespace: {ns}")

    return "\n".join(lines)


@mcp.tool()
def create_environment(name: str, accreditation: str = "DEV") -> str:
    """
    Create a new Apollo environment and install the Apollo Control Plane module.

    This creates a blank environment and auto-installs the builtin control plane module
    (module:builtin-control-plane-minimal:0.0.40) which is required for all environments.

    Use this for creating a fresh empty environment. To clone an existing environment
    with all its modules and entities, use replicate_environment instead.

    Args:
        name: Environment name (e.g. "my-cube-test"). Must be unique across Apollo.
        accreditation: Accreditation level. One of: DEV, STANDARD, FEDRAMP, FEDRAMP_MODERATE,
                       FEDRAMP_HIGH, FEDRAMP_HIGH_AND_IL4, IL5, IL6, GOVCLOUD, HIGHSIDE.
                       Defaults to DEV.
    """
    # Step 1: Create the environment
    create_mutation = """
    mutation CreateEnv($request: CreateApolloEnvironmentRequest!) {
        apollo {
            createEnvironment(request: $request) {
                id
                rid
                name
                spaceId
                namespaceId
                accreditation
            }
        }
    }
    """
    create_vars = {
        "request": {
            "requestedId": name,
            "accreditation": accreditation.upper(),
        }
    }

    data, error = _apollo_graphql_query(create_mutation, create_vars)
    if error:
        return f"Error creating environment: {error}"

    env = data.get("apollo", {}).get("createEnvironment", {})
    env_id = env.get("id", "")
    env_rid = env.get("rid", "")
    env_name = env.get("name", name)

    # Step 2: Install Apollo Control Plane module
    install_mutation = """
    mutation InstallModule($request: InstallApolloProductModuleInputV2!) {
        apollo {
            installModuleV2(request: $request) {
                rid
                displayName
                moduleMavenCoordinate
                state {
                    __typename
                }
            }
        }
    }
    """
    install_vars = {
        "request": {
            "apolloEnvironmentId": env_id,
            "mavenCoordinate": "module:builtin-control-plane-minimal:0.0.40",
            "displayName": "Apollo Control Plane",
            "variables": [
                {
                    "variableName": "environmentId",
                    "variableValue": {"stringValue": name},
                }
            ],
            "options": [],
        }
    }

    module_data, module_error = _apollo_graphql_query(install_mutation, install_vars)

    lines = [
        "Environment Created",
        "=" * 50,
        f"  Name:          {env_name}",
        f"  ID:            {env_id}",
        f"  RID:           {env_rid}",
        f"  Space:         {env.get('spaceId', '')}",
        f"  Namespace:     {env.get('namespaceId', '')}",
        f"  Accreditation: {env.get('accreditation', '')}",
        "",
    ]

    if module_error:
        lines.append(f"⚠ Module install failed: {module_error}")
        lines.append("  Environment was created but Apollo Control Plane was not installed.")
        lines.append("  You may need to install it manually from the Apollo UI.")
    else:
        module = module_data.get("apollo", {}).get("installModuleV2", {})
        state = module.get("state", {}).get("__typename", "").replace("ApolloProductModuleInstallationStateV2_", "")
        lines.append(f"Apollo Control Plane: {state}")
        lines.append(f"  Module: {module.get('moduleMavenCoordinate', '')}")
        lines.append(f"  RID:    {module.get('rid', '')}")

    return "\n".join(lines)


@mcp.tool()
def replicate_environment(source: str, target: str, accreditation: str = "DEV") -> str:
    """
    Replicate an Apollo environment into a new or existing environment.

    Copies from the source environment:
    - All module installations with their variables and versions (skips already-installed)
    - All directly-installed entities (not from modules) with config overrides (skips existing)
    - Removes extra entities on the target that don't exist on source (cleanup)
    - Reports secret names and key structure (values must be set manually after agent connects)

    Important behavior:
    - Entities inherit the target environment's default release channel (no per-entity overrides).
    - If a module install fails (ModuleInstallationInvalid), its entities won't be created.
      Use install_entity to manually add specific entities that are missing.
    - Secrets cannot be created until an agent connects to the environment.

    Args:
        source: Source environment name or RID to replicate from (e.g. "pep-onlogicmc610-6")
        target: Target environment name. If it already exists, replicates into it.
                If it doesn't exist, creates a new environment with this name.
        accreditation: Accreditation level for new environments (DEV, STANDARD, etc).
                       Ignored if target already exists. Defaults to DEV.
    """
    lines = []

    # --- Resolve source environment ---
    source_info, error = _resolve_environment(source)
    if error:
        return f"Error resolving source: {error}"
    source_id = source_info["id"]
    source_name = source_info.get("name", source)

    # --- Check if target already exists ---
    target_info, _ = _resolve_environment(target)

    # --- Fetch source modules, entities, and secrets ---
    source_query = """
    query SourceEnv($id: String!) {
        apollo {
            environmentById(id: $id) {
                name
                accreditation
                moduleInstallationsV2 {
                    moduleInstallations {
                        displayName
                        moduleMavenCoordinate
                        variables {
                            variableName
                            variableValue {
                                __typename
                                ... on ApolloProductModuleVariableValue_StringValue { value }
                                ... on ApolloProductModuleVariableValue_OptionValue { value }
                            }
                        }
                    }
                }
                entities(pageSize: 200) {
                    entities {
                        id
                        entityLocator {
                            ... on ApolloEntityLocator_ApolloEntity { entityName type }
                        }
                        product { productId }
                        declaredState { releaseChannel }
                        installedByModuleV2 { rid }
                        reportedConfigOverrides {
                            ... on ApolloEntityReportedConfigOverrides_Default {
                                overrides
                            }
                        }
                        secrets {
                            name
                            specification {
                                ... on ApolloSecretSpecification_MultiKey {
                                    keys { key }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    """
    source_data, error = _apollo_graphql_query(source_query, {"id": source_id})
    if error:
        return f"Error reading source environment: {error}"

    source_env = source_data.get("apollo", {}).get("environmentById")
    if not source_env:
        return f"Source environment not found: {source_id}"

    source_modules = source_env.get("moduleInstallationsV2", {}).get("moduleInstallations", [])
    all_entities = source_env.get("entities", {}).get("entities", [])
    direct_entities = [e for e in all_entities if e.get("installedByModuleV2") is None]

    # Collect secrets info
    secrets_info = []
    for e in all_entities:
        for s in e.get("secrets", []):
            ename = e.get("entityLocator", {}).get("entityName", "?")
            keys = [k["key"] for k in (s.get("specification") or {}).get("keys", [])]
            secrets_info.append({"entity": ename, "name": s["name"], "keys": keys})

    lines.append(f"Replicating: {source_name} -> {target}")
    lines.append("=" * 60)
    lines.append(f"Source: {len(source_modules)} modules, {len(all_entities)} entities ({len(direct_entities)} direct), {len(secrets_info)} secrets")
    lines.append("")

    # --- Step 1: Create or use existing target environment ---
    if target_info:
        target_id = target_info["id"]
        target_display = target_info.get("name", target)
        lines.append(f"1. Using existing environment: {target_display}")
        lines.append(f"   ID: {target_id}")
        lines.append(f"   RID: {target_info.get('rid', '')}")
    else:
        create_mutation = """
        mutation CreateEnv($request: CreateApolloEnvironmentRequest!) {
            apollo {
                createEnvironment(request: $request) {
                    id
                    rid
                    name
                    spaceId
                    namespaceId
                    accreditation
                }
            }
        }
        """
        create_data, error = _apollo_graphql_query(create_mutation, {
            "request": {"requestedId": target, "accreditation": accreditation.upper()}
        })
        if error:
            return f"Error creating target environment: {error}"

        target_env = create_data.get("apollo", {}).get("createEnvironment", {})
        target_id = target_env.get("id", "")
        target_display = target_env.get("name", target)
        lines.append(f"1. Environment created: {target_display}")
        lines.append(f"   ID: {target_id}")
        lines.append(f"   RID: {target_env.get('rid', '')}")

    lines.append("")

    # --- Fetch target's existing modules and entities to skip duplicates ---
    target_query = """
    query TargetEnv($id: String!) {
        apollo {
            environmentById(id: $id) {
                moduleInstallationsV2 {
                    moduleInstallations {
                        moduleMavenCoordinate
                    }
                }
                entities(pageSize: 200) {
                    entities {
                        entityLocator {
                            ... on ApolloEntityLocator_ApolloEntity { entityName }
                        }
                    }
                }
            }
        }
    }
    """
    target_data, _ = _apollo_graphql_query(target_query, {"id": target_id})
    target_env_data = (target_data or {}).get("apollo", {}).get("environmentById", {})

    existing_module_coords = {
        m["moduleMavenCoordinate"]
        for m in target_env_data.get("moduleInstallationsV2", {}).get("moduleInstallations", [])
    }
    existing_entity_names = {
        e.get("entityLocator", {}).get("entityName", "")
        for e in target_env_data.get("entities", {}).get("entities", [])
    }

    # --- Step 2: Install modules ---
    install_module_mutation = """
    mutation InstallModule($request: InstallApolloProductModuleInputV2!) {
        apollo {
            installModuleV2(request: $request) {
                rid
                displayName
                moduleMavenCoordinate
                state { __typename }
            }
        }
    }
    """

    lines.append("2. Modules:")
    module_ok = 0
    module_skip = 0
    module_fail = 0
    for m in source_modules:
        coord = m["moduleMavenCoordinate"]
        display_name = m.get("displayName") or ""

        if coord in existing_module_coords:
            lines.append(f"   SKIP {display_name or coord} (already installed)")
            module_skip += 1
            continue

        # Convert variables to input format
        var_inputs = []
        for v in m.get("variables", []):
            vtype = v["variableValue"]["__typename"]
            val = v["variableValue"]["value"]
            kind = "stringValue" if "String" in vtype else "optionValue"
            var_inputs.append({
                "variableName": v["variableName"],
                "variableValue": {kind: val},
            })

        request = {
            "apolloEnvironmentId": target_id,
            "mavenCoordinate": coord,
            "variables": var_inputs,
            "options": [],
        }
        if display_name:
            request["displayName"] = display_name

        data, error = _apollo_graphql_query(install_module_mutation, {"request": request})
        if error:
            lines.append(f"   FAIL {display_name or coord}: {error}")
            module_fail += 1
        else:
            result = data.get("apollo", {}).get("installModuleV2", {})
            state = result.get("state", {}).get("__typename", "").replace(
                "ApolloProductModuleInstallationStateV2_", ""
            )
            lines.append(f"   OK   {display_name or coord} -> {state}")
            module_ok += 1

    lines.append(f"   ({module_ok} installed, {module_skip} skipped, {module_fail} failed)")
    lines.append("")

    # --- Step 3: Install direct entities (skip existing) ---
    # Re-fetch target entities after module installs (modules may have added entities)
    target_data2, _ = _apollo_graphql_query(target_query, {"id": target_id})
    target_env_data2 = (target_data2 or {}).get("apollo", {}).get("environmentById", {})
    existing_entity_names = {
        e.get("entityLocator", {}).get("entityName", "")
        for e in target_env_data2.get("entities", {}).get("entities", [])
    }

    new_direct = [
        e for e in direct_entities
        if e.get("entityLocator", {}).get("entityName", "") not in existing_entity_names
    ]
    skipped_direct = len(direct_entities) - len(new_direct)

    if new_direct:
        helm_charts = []
        for e in new_direct:
            locator = e.get("entityLocator", {})
            ename = locator.get("entityName", "")
            product_id = e.get("product", {}).get("productId", "")
            overrides = (e.get("reportedConfigOverrides") or {}).get("overrides")

            config_str = (
                f"0.0.0:\n  overrides: {json.dumps(overrides)}"
                if overrides
                else "0.0.0:\n  overrides: {}"
            )

            optional = {"configOverrides": config_str}

            helm_charts.append({
                "helmChartName": ename,
                "productId": product_id,
                "k8sNamespace": "default",
                "entityInstallationOptionalInputs": optional,
            })

        install_entities_mutation = """
        mutation InstallEntities($envId: String!, $entities: InstallApolloEntitiesOnEnvironmentRequest!) {
            apollo {
                installEntitiesOnEnvironment(apolloEnvironmentId: $envId, entities: $entities) {
                    rid
                    title
                    requestStatus
                }
            }
        }
        """
        data, error = _apollo_graphql_query(install_entities_mutation, {
            "envId": target_id,
            "entities": {"helmCharts": helm_charts},
        })

        lines.append(f"3. Direct entities ({len(helm_charts)} new, {skipped_direct} skipped):")
        if error:
            lines.append(f"   FAIL: {error}")
        else:
            cr = data.get("apollo", {}).get("installEntitiesOnEnvironment", {})
            lines.append(f"   Change request: {cr.get('requestStatus', '?')}")
            for hc in helm_charts:
                lines.append(f"   - {hc['helmChartName']} ({hc['productId']})")
    else:
        lines.append(f"3. Direct entities: {skipped_direct} already exist, nothing new to install.")

    lines.append("")

    # --- Step 4: Uninstall extra entities not on source ---
    # Modules may install default entities that the source env had removed.
    # Compare and uninstall any extras.
    source_entity_names = {
        e.get("entityLocator", {}).get("entityName", "")
        for e in all_entities
    }

    cleanup_query = """
    query CleanupCheck($id: String!) {
        apollo {
            environmentById(id: $id) {
                entities(pageSize: 200) {
                    entities {
                        rid
                        entityLocator {
                            ... on ApolloEntityLocator_ApolloEntity { entityName }
                        }
                    }
                }
            }
        }
    }
    """
    cleanup_data, _ = _apollo_graphql_query(cleanup_query, {"id": target_id})
    target_entities_now = (cleanup_data or {}).get("apollo", {}).get(
        "environmentById", {}
    ).get("entities", {}).get("entities", [])

    extras = [
        e for e in target_entities_now
        if e.get("entityLocator", {}).get("entityName", "") not in source_entity_names
    ]

    uninstall_mutation = """
    mutation UninstallEntity($rid: RID!) {
        apollo {
            uninstallEntity(rid: $rid) {
                rid
                requestStatus
            }
        }
    }
    """

    if extras:
        lines.append(f"4. Cleanup ({len(extras)} extra entities to uninstall):")
        uninstall_ok = 0
        uninstall_fail = 0
        for e in extras:
            ename = e.get("entityLocator", {}).get("entityName", "?")
            erid = e.get("rid", "")
            data, error = _apollo_graphql_query(uninstall_mutation, {"rid": erid})
            if error:
                lines.append(f"   FAIL {ename}: {error}")
                uninstall_fail += 1
            else:
                cr = data.get("apollo", {}).get("uninstallEntity", {})
                lines.append(f"   OK   {ename}: {cr.get('requestStatus', '?')}")
                uninstall_ok += 1
        lines.append(f"   ({uninstall_ok} uninstalled, {uninstall_fail} failed)")
    else:
        lines.append("4. Cleanup: no extra entities to remove.")

    lines.append("")

    # --- Step 5: Report missing entities and secrets ---
    # Re-check for any entities still missing
    final_data, _ = _apollo_graphql_query(cleanup_query, {"id": target_id})
    final_entities = {
        e.get("entityLocator", {}).get("entityName", "")
        for e in (final_data or {}).get("apollo", {}).get(
            "environmentById", {}
        ).get("entities", {}).get("entities", [])
    }
    missing = source_entity_names - final_entities
    if missing:
        lines.append(f"5. Missing entities ({len(missing)}):")
        lines.append("   These entities could not be replicated (module install may have failed):")
        for name in sorted(missing):
            lines.append(f"   - {name}")
        lines.append("")

    # --- Step 6: Report secrets ---
    if secrets_info:
        step = "6" if missing else "5"
        lines.append(f"{step}. Secrets ({len(secrets_info)} found):")
        lines.append("   Secrets cannot be created until an agent connects to the environment.")
        lines.append("   The following secrets need to be configured manually:")
        for s in secrets_info:
            keys_str = ", ".join(s["keys"]) if s["keys"] else "(single value)"
            lines.append(f"   - {s['entity']}/{s['name']} [keys: {keys_str}]")
    else:
        step = "6" if missing else "5"
        lines.append(f"{step}. No secrets to replicate.")

    return "\n".join(lines)


@mcp.tool()
def install_entity(
    environment: str,
    entity_name: str,
    product_id: str,
    k8s_namespace: str = "default",
    config_overrides: str = None,
) -> str:
    """
    Install a Helm chart entity on an Apollo environment.

    The entity will inherit the environment's default release channel (no per-entity override).
    Use this to manually install individual entities, e.g. when a module install failed
    during replicate_environment but you still need specific entities from it.

    To find the product_id for an entity, query the source environment's entities
    using entity_health or check the Apollo UI. The product_id is a maven coordinate
    in "groupId:artifactId" format.

    Args:
        environment: Target environment name or RID (e.g. "pep-onlogicmc610-4" or "staging-int")
        entity_name: Helm chart name (e.g. "mosquitto", "ingress-nginx", "cert-manager")
        product_id: Product maven coordinate in "groupId:artifactId" format
                    (e.g. "com.eclipse:mosquitto", "com.kubernetes:ingress-nginx")
        k8s_namespace: Kubernetes namespace to install into. Defaults to "default".
        config_overrides: Optional JSON string of config overrides (e.g. '{"key": "value"}').
    """
    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error resolving environment: {error}"
    env_id = env_info["id"]

    optional = {}

    if config_overrides:
        try:
            overrides = json.loads(config_overrides)
            optional["configOverrides"] = f"0.0.0:\n  overrides: {json.dumps(overrides)}"
        except json.JSONDecodeError:
            return f"Error: config_overrides must be valid JSON. Got: {config_overrides[:200]}"
    else:
        optional["configOverrides"] = "0.0.0:\n  overrides: {}"

    mutation = """
    mutation InstallEntities($envId: String!, $entities: InstallApolloEntitiesOnEnvironmentRequest!) {
        apollo {
            installEntitiesOnEnvironment(apolloEnvironmentId: $envId, entities: $entities) {
                rid
                title
                requestStatus
            }
        }
    }
    """

    helm_chart = {
        "helmChartName": entity_name,
        "productId": product_id,
        "k8sNamespace": k8s_namespace,
        "entityInstallationOptionalInputs": optional,
    }

    data, error = _apollo_graphql_query(mutation, {
        "envId": env_id,
        "entities": {"helmCharts": [helm_chart]},
    })

    if error:
        return f"Error installing entity: {error}"

    cr = data.get("apollo", {}).get("installEntitiesOnEnvironment", {})
    lines = [
        f"Entity installed on {env_info.get('name', environment)}",
        "=" * 50,
        f"  Entity:          {entity_name}",
        f"  Product:         {product_id}",
        f"  Namespace:       {k8s_namespace}",
        f"  Config:          {'custom overrides' if config_overrides else 'none'}",
        "",
        f"  Change Request:  {cr.get('requestStatus', '?')}",
        f"  CR RID:          {cr.get('rid', '')}",
    ]

    return "\n".join(lines)


ENTITY_HEALTH_FIELDS = """
    id
    rid
    displayName
    lifecycleStatus
    product { productId }
    entityLocator {
        ... on ApolloEntityLocator_ApolloEntity { entityName }
    }
    reportedStatus {
        version
        rolloutStatus
        configStatus
        healthStatus
        upgradeBlockerTypes
    }
    planStatus {
        __typename
    }
    nonGenericPlans(pageSize: 5) {
        plans {
            rid
            agentId
            status {
                type
                result { summary }
                reason { summary }
            }
            startTime
            endTime
            tasks {
                description { type summary }
                status {
                    type
                    updatedAt
                }
            }
        }
    }
"""


@mcp.tool()
def entity_health(environment: str, entity_id: str = None) -> str:
    """
    Get health and activity status of entities in an Apollo environment.

    Shows reported health, rollout/config status, plan status, and recent
    plan history (installs, enforce config, etc.) for each entity.

    Also useful for discovering entity IDs and product IDs when you need
    to use install_entity on another environment.

    Args:
        environment: Environment name (e.g. "pep-onlogicmc610-6", "staging-int") or RID
        entity_id: Optional entity display name to filter by (substring match).
                   If omitted, shows all entities in the environment.
    """
    # Resolve name to ID
    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error: {error}"
    environment_id = env_info["id"]
    env_name = env_info.get("name", environment)

    # Query all entities in the environment with health fields
    env_query = f"""
    query EnvironmentEntities($id: String!) {{
        apollo {{
            environmentById(id: $id) {{
                name
                entities(pageSize: 100) {{
                    entities {{
                        {ENTITY_HEALTH_FIELDS}
                    }}
                }}
            }}
        }}
    }}
    """
    data, error = _apollo_graphql_query(env_query, {"id": environment_id})
    if error:
        return f"Error: {error}"

    env = data.get("apollo", {}).get("environmentById")
    if not env:
        return f"Environment not found: {environment_id}"

    all_entities = env.get("entities", {}).get("entities", [])
    if not all_entities:
        return f"No entities found in environment: {env.get('name', env_name)}"

    # Filter to specific entity if requested
    if entity_id:
        search = entity_id.lower()
        matched = [e for e in all_entities if search in (e.get("displayName") or e.get("id") or "").lower()]
        if not matched:
            names = [e.get("displayName") or e.get("id") for e in all_entities]
            return f"Entity '{entity_id}' not found. Available entities:\n" + "\n".join(f"  - {n}" for n in names)
        entities = matched
    else:
        entities = all_entities

    lines = [f"Entity Health: {env.get('name', env_name)}", "=" * 50]
    lines.append(f"{len(entities)} entit{'y' if len(entities) == 1 else 'ies'}\n")

    for entity in entities:
        lines.extend(_format_entity_health(entity))
        lines.append("")

    return "\n".join(lines)


@mcp.tool()
def plan_details(environment: str, entity_name: str, plan_index: int = 0) -> str:
    """
    Get detailed plan information for an entity, including tasks, events, and error logs.

    This is the key observability tool for understanding why an installation succeeded,
    failed, or is still in progress. Shows full task breakdown with status, duration,
    and event messages (including Helm error output for failed deployments).

    Use entity_health first to see a summary, then plan_details to deep-dive into
    a specific entity's plan.

    Args:
        environment: Environment name or RID (e.g. "pep-onlogicmc610-4", "staging-int")
        entity_name: Entity display name or entity name (substring match, e.g. "mosquitto", "redpanda-connect-20")
        plan_index: Which plan to show (0 = most recent, 1 = second most recent, etc.). Defaults to 0.
    """
    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error: {error}"
    env_id = env_info["id"]
    env_name = env_info.get("name", environment)

    # Query entity with detailed plan info
    query = """
    query PlanDetails($id: String!) {
        apollo {
            environmentById(id: $id) {
                name
                entities(pageSize: 200) {
                    entities {
                        id
                        displayName
                        lifecycleStatus
                        product { productId }
                        entityLocator {
                            ... on ApolloEntityLocator_ApolloEntity { entityName }
                        }
                        reportedStatus { version }
                        nonGenericPlans(pageSize: 10) {
                            plans {
                                rid
                                agentId
                                proposedTime
                                startTime
                                endTime
                                status {
                                    type
                                    result { type summary metadataMap }
                                    reason { type summary metadataMap }
                                }
                                stateChanges {
                                    entityType
                                    changeType
                                }
                                tasks {
                                    rid
                                    origin
                                    description { type summary }
                                    createdAt
                                    status {
                                        type
                                        updatedAt
                                        description { type summary metadataMap }
                                    }
                                    latestEvent {
                                        timestamp
                                        description { summary }
                                    }
                                    events {
                                        timestamp
                                        description { summary }
                                    }
                                    subtasks {
                                        origin
                                        description { type summary }
                                        createdAt
                                        status {
                                            type
                                            updatedAt
                                            description { type summary metadataMap }
                                        }
                                        latestEvent {
                                            timestamp
                                            description { summary }
                                        }
                                        events {
                                            timestamp
                                            description { summary }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(query, {"id": env_id})
    if error:
        return f"Error: {error}"

    env = data.get("apollo", {}).get("environmentById")
    if not env:
        return f"Environment not found: {env_id}"

    all_entities = env.get("entities", {}).get("entities", [])
    if not all_entities:
        return f"No entities in environment: {env_name}"

    # Find the entity (substring match on displayName or entityName)
    search = entity_name.lower()
    matched = [
        e for e in all_entities
        if search in (e.get("displayName") or "").lower()
        or search in (e.get("entityLocator", {}).get("entityName") or "").lower()
    ]

    if not matched:
        names = sorted(set(
            e.get("displayName") or e.get("entityLocator", {}).get("entityName", "") or e.get("id", "")
            for e in all_entities
        ))
        return f"Entity '{entity_name}' not found in {env_name}.\n\nAvailable entities:\n" + "\n".join(f"  - {n}" for n in names)

    if len(matched) > 1:
        # Try exact match
        exact = [e for e in matched if (e.get("displayName") or "").lower() == search or (e.get("entityLocator", {}).get("entityName") or "").lower() == search]
        if len(exact) == 1:
            matched = exact
        else:
            names = [e.get("displayName") or e.get("id") for e in matched]
            return f"Multiple entities match '{entity_name}':\n" + "\n".join(f"  - {n}" for n in names) + "\n\nPlease be more specific."

    entity = matched[0]
    ename = entity.get("displayName") or entity.get("id", "unknown")
    elocator = entity.get("entityLocator", {}).get("entityName", "")
    product_id = entity.get("product", {}).get("productId", "")
    lifecycle = entity.get("lifecycleStatus", "")
    version = (entity.get("reportedStatus") or {}).get("version", "")

    plans = entity.get("nonGenericPlans", {}).get("plans", [])
    if not plans:
        return f"No plans found for entity '{ename}' in {env_name}."

    if plan_index >= len(plans):
        return f"Plan index {plan_index} out of range. Entity '{ename}' has {len(plans)} plan(s) (use 0-{len(plans)-1})."

    plan = plans[plan_index]
    ps = plan.get("status", {}) or {}
    status_type = ps.get("type", "unknown")
    status_icon = _PLAN_STATUS_ICONS.get(status_type, "?")
    agent_id = plan.get("agentId", "unknown")
    start = plan.get("startTime", "")
    end = plan.get("endTime")
    duration = _format_duration(start, end)
    start_display = start[:19].replace("T", " ") if start else ""
    end_display = end[:19].replace("T", " ") if end else ""

    # Determine plan description from state changes
    changes = plan.get("stateChanges", [])
    change_types = [c.get("changeType", "") for c in changes]
    plan_type = "Install" if "INSTALL" in " ".join(change_types).upper() else "Plan"
    if any("ENFORCE" in ct.upper() or "CONFIG" in ct.upper() for ct in change_types):
        plan_type = "Enforce config"
    elif any("UPGRADE" in ct.upper() for ct in change_types):
        plan_type = "Upgrade"
    elif any("UNINSTALL" in ct.upper() for ct in change_types):
        plan_type = "Uninstall"

    # Build output
    lines = [
        f"Plan Details: {ename} on {env_name}",
        "=" * 60,
        "",
        f"  {status_icon} {plan_type} {version or ''}",
        f"  Status:     {status_type}",
        f"  Agent:      {agent_id}",
        f"  Product:    {product_id}",
        f"  Lifecycle:  {lifecycle}",
    ]
    if start_display:
        lines.append(f"  Started:    {start_display}")
    if end_display:
        lines.append(f"  Ended:      {end_display}")
    if duration:
        lines.append(f"  Duration:   {duration}")

    # Result/reason (error details for failed plans)
    result = ps.get("result")
    reason = ps.get("reason")
    if result:
        lines.append("")
        lines.append(f"  Result: {result.get('summary', '')}")
        metadata = result.get("metadataMap")
        if metadata:
            # metadataMap may contain detailed error logs
            if isinstance(metadata, dict):
                details = metadata.get("details") or metadata.get("resultDetails") or metadata.get("message")
                if details:
                    lines.append("")
                    lines.append("  Result Details:")
                    lines.append("  " + "-" * 56)
                    # Format the details - could be multi-line
                    if isinstance(details, str):
                        for dl in details.split("\n"):
                            lines.append(f"  {dl}")
                    else:
                        lines.append(f"  {json.dumps(details, indent=2)}")
                    lines.append("  " + "-" * 56)
                else:
                    # Show all metadata keys
                    for k, v in metadata.items():
                        if isinstance(v, str) and len(v) > 200:
                            lines.append(f"  {k}: {v[:200]}...")
                        else:
                            lines.append(f"  {k}: {v}")

    if reason and reason.get("summary"):
        lines.append(f"  Reason: {reason.get('summary', '')}")

    # Tasks
    tasks = plan.get("tasks", [])
    if tasks:
        lines.append("")
        lines.append("Tasks:")
        lines.append("-" * 60)
        for task in tasks:
            _format_plan_task(task, lines, indent=2)

    lines.append("")
    if plan_index == 0 and len(plans) > 1:
        lines.append(f"This is the most recent plan. Use plan_index=1..{len(plans)-1} to see older plans.")

    return "\n".join(lines)


def _format_plan_task(task: dict, lines: list, indent: int = 2):
    """Format a single plan task with its events into readable lines."""
    prefix = " " * indent
    desc = (task.get("description") or {})
    task_name = desc.get("summary") or desc.get("type", "unknown task")
    origin = task.get("origin", "")
    ts = task.get("status") or {}
    status_type = ts.get("type", "unknown")
    status_icon = _TASK_STATUS_ICONS.get(status_type, "?")
    created = task.get("createdAt", "")
    updated = (ts.get("updatedAt") or "")
    duration = _format_duration(created, updated if updated else None)

    # Task header
    task_line = f"{prefix}{status_icon} {task_name}"
    if origin:
        task_line += f" ({origin})"
    task_line += f" {status_type}"
    if duration:
        task_line += f" {duration}"
    lines.append(task_line)

    # Task status description (contains failure reason for failed tasks)
    task_status_desc = ts.get("description")
    if task_status_desc:
        summary = task_status_desc.get("summary", "")
        if summary:
            lines.append(f"{prefix}  └ {summary}")
        metadata = task_status_desc.get("metadataMap")
        if metadata and isinstance(metadata, dict):
            details = metadata.get("details") or metadata.get("message")
            if details and isinstance(details, str):
                # Show first few lines of details
                detail_lines = details.strip().split("\n")
                for dl in detail_lines[:10]:
                    lines.append(f"{prefix}    {dl}")
                if len(detail_lines) > 10:
                    lines.append(f"{prefix}    ... ({len(detail_lines) - 10} more lines)")

    # Events (show last few for context)
    events = task.get("events", [])
    if events:
        # Show up to 8 most recent events
        show_events = events[-8:] if len(events) > 8 else events
        if len(events) > 8:
            lines.append(f"{prefix}  ({len(events)} events, showing last 8)")
        for event in show_events:
            ts_str = event.get("timestamp", "")
            time_display = ts_str[11:19] if len(ts_str) >= 19 else ts_str
            event_desc = (event.get("description") or {}).get("summary", "")
            if event_desc:
                lines.append(f"{prefix}  {time_display} {event_desc}")
    elif not task_status_desc:
        # If no events and no status description, show latest event
        latest = task.get("latestEvent")
        if latest:
            event_desc = (latest.get("description") or {}).get("summary", "")
            if event_desc:
                lines.append(f"{prefix}  └ {event_desc}")

    # Subtasks
    subtasks = task.get("subtasks", [])
    for subtask in subtasks:
        _format_plan_task(subtask, lines, indent=indent + 2)


@mcp.tool()
def install_module(
    environment: str,
    maven_coordinate: str,
    variables: str = None,
    display_name: str = None,
    ignore_secret_requirements: bool = False,
) -> str:
    """
    Install a module on an Apollo environment.

    Modules are collections of related entities managed together. They define
    what software gets deployed and how. Common modules include control planes,
    monitoring stacks, and application bundles.

    If the module is already installed, this will return an error. Use
    update_module_variables to change variables on an existing installation.

    Args:
        environment: Target environment name or RID (e.g. "pep-onlogicmc610-4")
        maven_coordinate: Module maven coordinate (e.g. "module:builtin-control-plane-minimal:0.0.40")
        variables: Optional JSON object of module variables as {"varName": "value", ...}.
                   Values are treated as strings. Example: '{"environmentId": "my-env"}'
        display_name: Optional display name for the module installation.
        ignore_secret_requirements: If true, allows installation even if required secrets are not set.
    """
    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error resolving environment: {error}"
    env_id = env_info["id"]
    env_name = env_info.get("name", environment)

    # Parse variables
    var_inputs = []
    if variables:
        try:
            var_dict = json.loads(variables)
            for k, v in var_dict.items():
                var_inputs.append({
                    "variableName": k,
                    "variableValue": {"stringValue": str(v)},
                })
        except json.JSONDecodeError:
            return f"Error: variables must be valid JSON. Got: {variables[:200]}"

    mutation = """
    mutation InstallModule($request: InstallApolloProductModuleInputV2!) {
        apollo {
            installModuleV2(request: $request) {
                rid
                displayName
                moduleMavenCoordinate
                state { __typename }
                variables {
                    variableName
                    variableValue {
                        __typename
                        ... on ApolloProductModuleVariableValue_StringValue { value }
                        ... on ApolloProductModuleVariableValue_OptionValue { value }
                    }
                }
            }
        }
    }
    """

    request = {
        "apolloEnvironmentId": env_id,
        "mavenCoordinate": maven_coordinate,
        "variables": var_inputs,
        "options": [],
    }
    if display_name:
        request["displayName"] = display_name

    data, error = _apollo_graphql_query(mutation, {"request": request})
    if error:
        return f"Error installing module: {error}"

    result = data.get("apollo", {}).get("installModuleV2", {})
    state = result.get("state", {}).get("__typename", "").replace(
        "ApolloProductModuleInstallationStateV2_", ""
    )

    lines = [
        f"Module installed on {env_name}",
        "=" * 50,
        f"  Module:      {result.get('displayName', maven_coordinate)}",
        f"  Coordinate:  {result.get('moduleMavenCoordinate', maven_coordinate)}",
        f"  State:       {state}",
        f"  RID:         {result.get('rid', '')}",
    ]

    installed_vars = result.get("variables", [])
    if installed_vars:
        lines.append("")
        lines.append("  Variables:")
        for v in installed_vars:
            val = v.get("variableValue", {}).get("value", "")
            lines.append(f"    {v['variableName']} = {val}")

    return "\n".join(lines)


@mcp.tool()
def uninstall_entity(
    environment: str,
    entity_name: str,
) -> str:
    """
    Uninstall an entity from an Apollo environment.

    Removes the specified entity (Helm chart) from the environment. The entity
    will be uninstalled via a change request. Use entity_health to find entity
    names and plan_details to monitor the uninstall progress.

    Args:
        environment: Environment name or RID (e.g. "pep-onlogicmc610-4")
        entity_name: Entity display name or entity name (substring match, e.g. "mosquitto")
    """
    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error resolving environment: {error}"
    env_id = env_info["id"]
    env_name = env_info.get("name", environment)

    # Find the entity RID
    find_query = """
    query FindEntity($id: String!) {
        apollo {
            environmentById(id: $id) {
                entities(pageSize: 200) {
                    entities {
                        id
                        rid
                        displayName
                        entityLocator {
                            ... on ApolloEntityLocator_ApolloEntity { entityName }
                        }
                        product { productId }
                    }
                }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(find_query, {"id": env_id})
    if error:
        return f"Error querying entities: {error}"

    entities = data.get("apollo", {}).get("environmentById", {}).get("entities", {}).get("entities", [])
    if not entities:
        return f"No entities found in environment: {env_name}"

    # Match entity by name
    search = entity_name.lower()
    matched = [
        e for e in entities
        if search in (e.get("displayName") or "").lower()
        or search in (e.get("entityLocator", {}).get("entityName") or "").lower()
    ]

    if not matched:
        names = sorted(set(
            e.get("displayName") or e.get("entityLocator", {}).get("entityName", "") or e.get("id", "")
            for e in entities
        ))
        return f"Entity '{entity_name}' not found in {env_name}.\n\nAvailable entities:\n" + "\n".join(f"  - {n}" for n in names)

    if len(matched) > 1:
        exact = [e for e in matched if (e.get("displayName") or "").lower() == search or (e.get("entityLocator", {}).get("entityName") or "").lower() == search]
        if len(exact) == 1:
            matched = exact
        else:
            names = [e.get("displayName") or e.get("id") for e in matched]
            return f"Multiple entities match '{entity_name}':\n" + "\n".join(f"  - {n}" for n in names) + "\n\nPlease be more specific."

    entity = matched[0]
    entity_rid = entity.get("rid", "")
    entity_display = entity.get("displayName") or entity.get("entityLocator", {}).get("entityName", "unknown")
    product_id = entity.get("product", {}).get("productId", "")

    # Uninstall
    mutation = """
    mutation UninstallEntity($rid: RID!) {
        apollo {
            uninstallEntity(rid: $rid) {
                rid
                requestStatus
            }
        }
    }
    """
    data, error = _apollo_graphql_query(mutation, {"rid": entity_rid})
    if error:
        return f"Error uninstalling entity: {error}"

    cr = data.get("apollo", {}).get("uninstallEntity", {})
    lines = [
        f"Entity uninstall requested on {env_name}",
        "=" * 50,
        f"  Entity:         {entity_display}",
        f"  Product:        {product_id}",
        f"  Entity RID:     {entity_rid}",
        "",
        f"  Change Request: {cr.get('requestStatus', '?')}",
        f"  CR RID:         {cr.get('rid', '')}",
        "",
        "Use plan_details to monitor the uninstall progress.",
    ]

    return "\n".join(lines)


@mcp.tool()
def uninstall_module(
    environment: str,
    module_name: str,
) -> str:
    """
    Uninstall (unlink) a module from an Apollo environment.

    This removes the module installation but does NOT automatically remove
    the entities it installed. Entities installed by the module will become
    "direct" entities. Use uninstall_entity separately to remove individual
    entities if needed.

    Use list_modules to find installed modules and their RIDs.

    Args:
        environment: Environment name or RID (e.g. "pep-onlogicmc610-4")
        module_name: Module display name or maven coordinate substring to match
                     (e.g. "control-plane", "builtin-control-plane-minimal")
    """
    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error resolving environment: {error}"
    env_id = env_info["id"]
    env_name = env_info.get("name", environment)

    # Find modules
    find_query = """
    query FindModules($id: String!) {
        apollo {
            environmentById(id: $id) {
                moduleInstallationsV2 {
                    moduleInstallations {
                        rid
                        displayName
                        moduleMavenCoordinate
                        state { __typename }
                    }
                }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(find_query, {"id": env_id})
    if error:
        return f"Error querying modules: {error}"

    modules = data.get("apollo", {}).get("environmentById", {}).get(
        "moduleInstallationsV2", {}
    ).get("moduleInstallations", [])

    if not modules:
        return f"No modules found in environment: {env_name}"

    search = module_name.lower()
    matched = [
        m for m in modules
        if search in (m.get("displayName") or "").lower()
        or search in (m.get("moduleMavenCoordinate") or "").lower()
    ]

    if not matched:
        names = [f"{m.get('displayName', '')} ({m.get('moduleMavenCoordinate', '')})" for m in modules]
        return f"Module '{module_name}' not found in {env_name}.\n\nInstalled modules:\n" + "\n".join(f"  - {n}" for n in names)

    if len(matched) > 1:
        names = [f"{m.get('displayName', '')} ({m.get('moduleMavenCoordinate', '')})" for m in matched]
        return f"Multiple modules match '{module_name}':\n" + "\n".join(f"  - {n}" for n in names) + "\n\nPlease be more specific."

    module = matched[0]
    module_rid = module.get("rid", "")
    module_display = module.get("displayName") or module.get("moduleMavenCoordinate", "unknown")

    # Unlink module
    mutation = """
    mutation UnlinkModule($rid: RID!) {
        apollo {
            unlinkModule(installationRid: $rid) {
                rid
                displayName
                moduleMavenCoordinate
                state { __typename }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(mutation, {"rid": module_rid})
    if error:
        return f"Error unlinking module: {error}"

    result = data.get("apollo", {}).get("unlinkModule", {})
    state = result.get("state", {}).get("__typename", "").replace(
        "ApolloProductModuleInstallationStateV2_", ""
    )

    lines = [
        f"Module unlinked from {env_name}",
        "=" * 50,
        f"  Module:      {result.get('displayName', module_display)}",
        f"  Coordinate:  {result.get('moduleMavenCoordinate', '')}",
        f"  State:       {state}",
        f"  RID:         {result.get('rid', module_rid)}",
        "",
        "Note: Entities installed by this module are now 'direct' entities.",
        "Use uninstall_entity to remove them individually if needed.",
    ]

    return "\n".join(lines)


@mcp.tool()
def list_modules(
    environment: str,
    search: str = None,
) -> str:
    """
    List modules installed on an Apollo environment.

    Shows all module installations with their display names, maven coordinates,
    state, variables, and RIDs. Use this to find module details before updating
    variables or unlinking a module.

    Args:
        environment: Environment name or RID (e.g. "pep-onlogicmc610-4")
        search: Optional substring to filter modules by name or coordinate (case-insensitive).
    """
    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error resolving environment: {error}"
    env_id = env_info["id"]
    env_name = env_info.get("name", environment)

    query = """
    query ListModules($id: String!) {
        apollo {
            environmentById(id: $id) {
                name
                moduleInstallationsV2 {
                    moduleInstallations {
                        rid
                        displayName
                        moduleMavenCoordinate
                        state { __typename }
                        variables {
                            variableName
                            variableValue {
                                __typename
                                ... on ApolloProductModuleVariableValue_StringValue { value }
                                ... on ApolloProductModuleVariableValue_OptionValue { value }
                            }
                        }
                    }
                }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(query, {"id": env_id})
    if error:
        return f"Error: {error}"

    env = data.get("apollo", {}).get("environmentById")
    if not env:
        return f"Environment not found: {env_id}"

    modules = env.get("moduleInstallationsV2", {}).get("moduleInstallations", [])

    if search:
        s = search.lower()
        modules = [
            m for m in modules
            if s in (m.get("displayName") or "").lower()
            or s in (m.get("moduleMavenCoordinate") or "").lower()
        ]

    if not modules:
        return f"No modules found in environment: {env_name}" + (f" matching '{search}'" if search else "")

    lines = [f"Modules in {env_name}", "=" * 50, f"{len(modules)} module(s)", ""]

    for m in modules:
        state = m.get("state", {}).get("__typename", "").replace(
            "ApolloProductModuleInstallationStateV2_", ""
        )
        lines.append(f"  {m.get('displayName', '(unnamed)')}")
        lines.append(f"    Coordinate: {m.get('moduleMavenCoordinate', '')}")
        lines.append(f"    State:      {state}")
        lines.append(f"    RID:        {m.get('rid', '')}")

        variables = m.get("variables", [])
        if variables:
            lines.append("    Variables:")
            for v in variables:
                val = v.get("variableValue", {}).get("value", "")
                lines.append(f"      {v['variableName']} = {val}")
        lines.append("")

    return "\n".join(lines)


@mcp.tool()
def update_module_variables(
    environment: str,
    module_name: str,
    variables: str,
) -> str:
    """
    Update variables on an installed module.

    Changes the variable values for an existing module installation. This triggers
    Apollo to reconcile the module's entities with the new variable values.

    Use list_modules to see current variable values before updating.

    Args:
        environment: Environment name or RID (e.g. "pep-onlogicmc610-4")
        module_name: Module display name or maven coordinate substring to match.
        variables: JSON object of variables to update as {"varName": "newValue", ...}.
                   Only specified variables will be updated; others remain unchanged.
    """
    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error resolving environment: {error}"
    env_id = env_info["id"]
    env_name = env_info.get("name", environment)

    # Parse variables
    try:
        var_dict = json.loads(variables)
    except json.JSONDecodeError:
        return f"Error: variables must be valid JSON. Got: {variables[:200]}"

    # Find the module
    find_query = """
    query FindModule($id: String!) {
        apollo {
            environmentById(id: $id) {
                moduleInstallationsV2 {
                    moduleInstallations {
                        rid
                        displayName
                        moduleMavenCoordinate
                        variables {
                            variableName
                            variableValue {
                                __typename
                                ... on ApolloProductModuleVariableValue_StringValue { value }
                                ... on ApolloProductModuleVariableValue_OptionValue { value }
                            }
                        }
                    }
                }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(find_query, {"id": env_id})
    if error:
        return f"Error querying modules: {error}"

    modules = data.get("apollo", {}).get("environmentById", {}).get(
        "moduleInstallationsV2", {}
    ).get("moduleInstallations", [])

    search = module_name.lower()
    matched = [
        m for m in modules
        if search in (m.get("displayName") or "").lower()
        or search in (m.get("moduleMavenCoordinate") or "").lower()
    ]

    if not matched:
        names = [f"{m.get('displayName', '')} ({m.get('moduleMavenCoordinate', '')})" for m in modules]
        return f"Module '{module_name}' not found in {env_name}.\n\nInstalled modules:\n" + "\n".join(f"  - {n}" for n in names)

    if len(matched) > 1:
        names = [f"{m.get('displayName', '')} ({m.get('moduleMavenCoordinate', '')})" for m in matched]
        return f"Multiple modules match '{module_name}':\n" + "\n".join(f"  - {n}" for n in names) + "\n\nPlease be more specific."

    module = matched[0]
    module_rid = module.get("rid", "")
    module_display = module.get("displayName") or module.get("moduleMavenCoordinate", "unknown")

    # Build merged variable list - keep existing, override with new
    existing_vars = {v["variableName"]: v for v in module.get("variables", [])}
    var_inputs = []
    updated_keys = []

    for v in module.get("variables", []):
        vname = v["variableName"]
        if vname in var_dict:
            var_inputs.append({
                "variableName": vname,
                "variableValue": {"stringValue": str(var_dict[vname])},
            })
            updated_keys.append(vname)
        else:
            vtype = v["variableValue"]["__typename"]
            val = v["variableValue"]["value"]
            kind = "stringValue" if "String" in vtype else "optionValue"
            var_inputs.append({
                "variableName": vname,
                "variableValue": {kind: val},
            })

    # Add any new variables not in existing
    for k, v in var_dict.items():
        if k not in existing_vars:
            var_inputs.append({
                "variableName": k,
                "variableValue": {"stringValue": str(v)},
            })
            updated_keys.append(k)

    mutation = """
    mutation UpdateModule($rid: RID!, $update: UpdateApolloProductModuleInstallationInputV2!) {
        apollo {
            updateModuleInstallationV2(moduleInstallationRid: $rid, update: $update) {
                rid
                displayName
                moduleMavenCoordinate
                state { __typename }
                variables {
                    variableName
                    variableValue {
                        __typename
                        ... on ApolloProductModuleVariableValue_StringValue { value }
                        ... on ApolloProductModuleVariableValue_OptionValue { value }
                    }
                }
            }
        }
    }
    """

    data, error = _apollo_graphql_query(mutation, {
        "rid": module_rid,
        "update": {
            "mavenCoordinate": module.get("moduleMavenCoordinate", ""),
            "variables": var_inputs,
            "options": [],
        },
    })
    if error:
        return f"Error updating module variables: {error}"

    result = data.get("apollo", {}).get("updateModuleInstallationV2", {})
    state = result.get("state", {}).get("__typename", "").replace(
        "ApolloProductModuleInstallationStateV2_", ""
    )

    lines = [
        f"Module variables updated on {env_name}",
        "=" * 50,
        f"  Module:      {result.get('displayName', module_display)}",
        f"  Coordinate:  {result.get('moduleMavenCoordinate', '')}",
        f"  State:       {state}",
        "",
        "  Updated variables:",
    ]
    for v in result.get("variables", []):
        val = v.get("variableValue", {}).get("value", "")
        marker = " *" if v["variableName"] in updated_keys else ""
        lines.append(f"    {v['variableName']} = {val}{marker}")

    lines.append("")
    lines.append("  (* = changed in this update)")

    return "\n".join(lines)


@mcp.tool()
def create_secret(
    environment: str,
    entity_name: str,
    secret_name: str,
    secret_value: str = None,
    secret_keys: str = None,
    description: str = "",
) -> str:
    """
    Create a secret for an entity in an Apollo environment.

    Secrets store sensitive values (passwords, API keys, certificates) that are
    delivered securely to entities. Secrets can be single-value or multi-key.

    IMPORTANT: Secrets can only be created after an agent has connected to the
    environment. If the agent hasn't connected yet, this will fail.

    For single-value secrets, provide secret_value.
    For multi-key secrets, provide secret_keys as a JSON object {"key1": "val1", "key2": "val2"}.

    Args:
        environment: Environment name or RID (e.g. "pep-onlogicmc610-4")
        entity_name: Entity display name or entity name (substring match)
        secret_name: Name for the secret (e.g. "mqtt-credentials", "tls-cert")
        secret_value: Value for a single-value secret. Mutually exclusive with secret_keys.
        secret_keys: JSON object of key-value pairs for a multi-key secret.
                     Example: '{"username": "admin", "password": "secret123"}'
        description: Optional description for the secret.
    """
    if not secret_value and not secret_keys:
        return "Error: Must provide either secret_value (single-value) or secret_keys (multi-key)."
    if secret_value and secret_keys:
        return "Error: Provide either secret_value or secret_keys, not both."

    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error resolving environment: {error}"
    env_id = env_info["id"]
    env_name = env_info.get("name", environment)

    # Find entity ID
    find_query = """
    query FindEntity($id: String!) {
        apollo {
            environmentById(id: $id) {
                entities(pageSize: 200) {
                    entities {
                        id
                        displayName
                        entityLocator {
                            ... on ApolloEntityLocator_ApolloEntity { entityName }
                        }
                    }
                }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(find_query, {"id": env_id})
    if error:
        return f"Error querying entities: {error}"

    entities = data.get("apollo", {}).get("environmentById", {}).get("entities", {}).get("entities", [])
    search = entity_name.lower()
    matched = [
        e for e in entities
        if search in (e.get("displayName") or "").lower()
        or search in (e.get("entityLocator", {}).get("entityName") or "").lower()
    ]

    if not matched:
        names = sorted(set(
            e.get("displayName") or e.get("entityLocator", {}).get("entityName", "") or e.get("id", "")
            for e in entities
        ))
        return f"Entity '{entity_name}' not found in {env_name}.\n\nAvailable entities:\n" + "\n".join(f"  - {n}" for n in names)

    if len(matched) > 1:
        exact = [e for e in matched if (e.get("displayName") or "").lower() == search or (e.get("entityLocator", {}).get("entityName") or "").lower() == search]
        if len(exact) == 1:
            matched = exact
        else:
            names = [e.get("displayName") or e.get("id") for e in matched]
            return f"Multiple entities match '{entity_name}':\n" + "\n".join(f"  - {n}" for n in names) + "\n\nPlease be more specific."

    entity = matched[0]
    apollo_entity_id = entity.get("id", "")
    entity_display = entity.get("displayName") or entity.get("entityLocator", {}).get("entityName", "unknown")

    # Build secret spec
    if secret_value:
        target_spec = {"singleValue": {"rawValue": secret_value}}
    else:
        try:
            keys_dict = json.loads(secret_keys)
        except json.JSONDecodeError:
            return f"Error: secret_keys must be valid JSON. Got: {secret_keys[:200]}"
        raw_values = [{"key": k, "rawValue": str(v)} for k, v in keys_dict.items()]
        target_spec = {"multiValue": {"rawValues": raw_values}}

    mutation = """
    mutation CreateSecrets($entityId: String!, $secrets: [ApolloCreateSecretRequest_Input!]!) {
        apollo {
            createSecrets(apolloEntityId: $entityId, secrets: $secrets)
        }
    }
    """

    secret_input = {
        "name": secret_name,
        "description": description or f"Secret {secret_name} for {entity_display}",
        "targetSpecification": target_spec,
    }

    data, error = _apollo_graphql_query(mutation, {
        "entityId": apollo_entity_id,
        "secrets": [secret_input],
    })
    if error:
        if "EntityDoesNotSupportSecretType" in error:
            return (
                f"Error: Entity '{entity_display}' does not support this secret type.\n"
                "Secrets must be defined in the product's Helm chart (via secretRef in values.yaml).\n"
                "The entity's product must declare the secret before it can be created."
            )
        return f"Error creating secret: {error}"

    rids = data.get("apollo", {}).get("createSecrets", [])

    lines = [
        f"Secret created on {env_name}",
        "=" * 50,
        f"  Entity:      {entity_display}",
        f"  Secret Name: {secret_name}",
        f"  Type:        {'single-value' if secret_value else 'multi-key'}",
    ]
    if secret_keys:
        try:
            keys_dict = json.loads(secret_keys)
            lines.append(f"  Keys:        {', '.join(keys_dict.keys())}")
        except json.JSONDecodeError:
            pass
    if rids:
        lines.append(f"  RID:         {rids[0] if rids else ''}")

    return "\n".join(lines)


@mcp.tool()
def update_secret(
    environment: str,
    entity_name: str,
    secret_name: str,
    secret_value: str = None,
    secret_key: str = None,
    secret_key_value: str = None,
    description: str = None,
) -> str:
    """
    Update an existing secret for an entity in an Apollo environment.

    Can update a single-value secret's value, set/update a specific key in a
    multi-key secret, or update the description.

    For single-value secrets: provide secret_value.
    For multi-key secrets: provide secret_key and secret_key_value to set a specific key.
    To update description only: provide just description.

    Args:
        environment: Environment name or RID (e.g. "pep-onlogicmc610-4")
        entity_name: Entity display name or entity name (substring match)
        secret_name: Name of the existing secret to update
        secret_value: New value for a single-value secret.
        secret_key: Key name to set/update in a multi-key secret.
        secret_key_value: Value for the key specified by secret_key.
        description: New description for the secret.
    """
    if not secret_value and not secret_key and not description:
        return "Error: Must provide secret_value, secret_key+secret_key_value, or description."
    if secret_key and not secret_key_value:
        return "Error: secret_key_value is required when secret_key is provided."

    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error resolving environment: {error}"
    env_id = env_info["id"]
    env_name = env_info.get("name", environment)

    # Find entity ID
    find_query = """
    query FindEntity($id: String!) {
        apollo {
            environmentById(id: $id) {
                entities(pageSize: 200) {
                    entities {
                        id
                        displayName
                        entityLocator {
                            ... on ApolloEntityLocator_ApolloEntity { entityName }
                        }
                    }
                }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(find_query, {"id": env_id})
    if error:
        return f"Error querying entities: {error}"

    entities = data.get("apollo", {}).get("environmentById", {}).get("entities", {}).get("entities", [])
    search = entity_name.lower()
    matched = [
        e for e in entities
        if search in (e.get("displayName") or "").lower()
        or search in (e.get("entityLocator", {}).get("entityName") or "").lower()
    ]

    if not matched:
        names = sorted(set(
            e.get("displayName") or e.get("entityLocator", {}).get("entityName", "") or e.get("id", "")
            for e in entities
        ))
        return f"Entity '{entity_name}' not found in {env_name}.\n\nAvailable entities:\n" + "\n".join(f"  - {n}" for n in names)

    if len(matched) > 1:
        exact = [e for e in matched if (e.get("displayName") or "").lower() == search or (e.get("entityLocator", {}).get("entityName") or "").lower() == search]
        if len(exact) == 1:
            matched = exact
        else:
            names = [e.get("displayName") or e.get("id") for e in matched]
            return f"Multiple entities match '{entity_name}':\n" + "\n".join(f"  - {n}" for n in names) + "\n\nPlease be more specific."

    entity = matched[0]
    apollo_entity_id = entity.get("id", "")
    entity_display = entity.get("displayName") or entity.get("entityLocator", {}).get("entityName", "unknown")

    # Build update request
    update_request = {}
    if secret_value:
        update_request["singleValueUpdate"] = {"value": secret_value}
    elif secret_key:
        update_request["multikeySetKey"] = {"key": secret_key, "value": secret_key_value}
    if description:
        update_request["updateDescription"] = {"description": description}

    mutation = """
    mutation UpdateSecrets($entityId: String!, $secrets: [ApolloUpdateSecretRequest_Input!]!) {
        apollo {
            updateSecretsByEntityId(apolloEntityId: $entityId, secrets: $secrets)
        }
    }
    """

    secret_update = {
        "name": secret_name,
        "request": update_request,
    }

    data, error = _apollo_graphql_query(mutation, {
        "entityId": apollo_entity_id,
        "secrets": [secret_update],
    })
    if error:
        return f"Error updating secret: {error}"

    success = data.get("apollo", {}).get("updateSecretsByEntityId", False)

    lines = [
        f"Secret updated on {env_name}",
        "=" * 50,
        f"  Entity:      {entity_display}",
        f"  Secret Name: {secret_name}",
        f"  Success:     {success}",
    ]
    if secret_value:
        lines.append(f"  Updated:     single value")
    if secret_key:
        lines.append(f"  Updated key: {secret_key}")
    if description:
        lines.append(f"  Description: {description}")

    return "\n".join(lines)


@mcp.tool()
def update_entity_config(
    environment: str,
    entity_name: str,
    config: str,
) -> str:
    """
    Update configuration overrides for an entity in an Apollo environment.

    Sets or replaces the config overrides for a specific entity. The config should
    be a JSON object of key-value pairs that will be passed as Helm value overrides.

    Use entity_health to see current entity status before updating config.

    Args:
        environment: Environment name or RID (e.g. "pep-onlogicmc610-4")
        entity_name: Entity display name or entity name (substring match)
        config: JSON object of config overrides (e.g. '{"replicaCount": 2, "image.tag": "v1.2.3"}')
    """
    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error resolving environment: {error}"
    env_id = env_info["id"]
    env_name = env_info.get("name", environment)

    # Parse config
    try:
        config_dict = json.loads(config)
    except json.JSONDecodeError:
        return f"Error: config must be valid JSON. Got: {config[:200]}"

    # Find entity
    find_query = """
    query FindEntity($id: String!) {
        apollo {
            environmentById(id: $id) {
                entities(pageSize: 200) {
                    entities {
                        id
                        displayName
                        entityLocator {
                            ... on ApolloEntityLocator_ApolloEntity { entityName }
                        }
                        product { productId }
                        reportedStatus { version }
                    }
                }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(find_query, {"id": env_id})
    if error:
        return f"Error querying entities: {error}"

    entities = data.get("apollo", {}).get("environmentById", {}).get("entities", {}).get("entities", [])
    search = entity_name.lower()
    matched = [
        e for e in entities
        if search in (e.get("displayName") or "").lower()
        or search in (e.get("entityLocator", {}).get("entityName") or "").lower()
    ]

    if not matched:
        names = sorted(set(
            e.get("displayName") or e.get("entityLocator", {}).get("entityName", "") or e.get("id", "")
            for e in entities
        ))
        return f"Entity '{entity_name}' not found in {env_name}.\n\nAvailable entities:\n" + "\n".join(f"  - {n}" for n in names)

    if len(matched) > 1:
        exact = [e for e in matched if (e.get("displayName") or "").lower() == search or (e.get("entityLocator", {}).get("entityName") or "").lower() == search]
        if len(exact) == 1:
            matched = exact
        else:
            names = [e.get("displayName") or e.get("id") for e in matched]
            return f"Multiple entities match '{entity_name}':\n" + "\n".join(f"  - {n}" for n in names) + "\n\nPlease be more specific."

    entity = matched[0]
    apollo_entity_id = entity.get("id", "")
    entity_display = entity.get("displayName") or entity.get("entityLocator", {}).get("entityName", "unknown")

    # Get namespace ID (required for config updates)
    ns_query = """
    query GetNs($id: String!) {
        apollo { environmentById(id: $id) { namespaceId } }
    }
    """
    ns_data, ns_err = _apollo_graphql_query(ns_query, {"id": env_id})
    if ns_err:
        return f"Error getting namespace: {ns_err}"
    namespace_id = ns_data.get("apollo", {}).get("environmentById", {}).get("namespaceId", "")

    # Build config content as JSON: {"0.0.0": {"overrides": {...}}}
    config_content = json.dumps({"0.0.0": {"overrides": config_dict}})

    mutation = """
    mutation UpdateConfig($update: ApolloChangeRequestUpdateConfig!) {
        apollo {
            updateConfigByResource(update: $update) {
                rid
                requestStatus
                title
            }
        }
    }
    """

    data, error = _apollo_graphql_query(mutation, {
        "update": {
            "type": {"entity": {"apolloEntityId": apollo_entity_id}},
            "content": config_content,
            "namespaceId": namespace_id,
        },
    })
    if error:
        return f"Error updating config: {error}"

    cr = data.get("apollo", {}).get("updateConfigByResource", {})

    lines = [
        f"Config update requested on {env_name}",
        "=" * 50,
        f"  Entity:         {entity_display}",
        f"  Entity ID:      {apollo_entity_id}",
        "",
        f"  Change Request: {cr.get('requestStatus', '?')}",
        f"  CR RID:         {cr.get('rid', '')}",
        f"  CR Title:       {cr.get('title', '')}",
        "",
        "  Config overrides:",
    ]
    for k, v in config_dict.items():
        lines.append(f"    {k}: {v}")

    lines.append("")
    lines.append("Use entity_health or plan_details to monitor the config enforcement.")

    return "\n".join(lines)


@mcp.tool()
def delete_environment(
    environment: str,
) -> str:
    """
    Delete an Apollo environment.

    Opens a change request to delete the specified environment. This is a
    destructive operation that will remove the environment and all its
    entities, modules, and configuration.

    Args:
        environment: Environment name or RID to delete (e.g. "my-test-env")
    """
    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error resolving environment: {error}"
    env_id = env_info["id"]
    env_name = env_info.get("name", environment)
    env_rid = env_info.get("rid", "")

    # We need namespace ID - fetch it
    query = """
    query GetEnv($id: String!) {
        apollo {
            environmentById(id: $id) {
                id
                rid
                name
                namespaceId
            }
        }
    }
    """
    data, error = _apollo_graphql_query(query, {"id": env_id})
    if error:
        return f"Error fetching environment details: {error}"

    env = data.get("apollo", {}).get("environmentById", {})
    namespace_id = env.get("namespaceId", "")

    mutation = """
    mutation DeleteEnv($namespaceId: String!, $id: String!) {
        apollo {
            deleteEnvironmentByIdV2(namespaceId: $namespaceId, id: $id) {
                rid
                requestStatus
                title
            }
        }
    }
    """

    data, error = _apollo_graphql_query(mutation, {
        "namespaceId": namespace_id,
        "id": env_id,
    })
    if error:
        return f"Error deleting environment: {error}"

    cr = data.get("apollo", {}).get("deleteEnvironmentByIdV2", {})

    lines = [
        f"Environment deletion requested",
        "=" * 50,
        f"  Environment:    {env_name}",
        f"  ID:             {env_id}",
        f"  Namespace:      {namespace_id}",
        "",
        f"  Change Request: {cr.get('requestStatus', '?')}",
        f"  CR RID:         {cr.get('rid', '')}",
        f"  CR Title:       {cr.get('title', '')}",
    ]

    return "\n".join(lines)


@mcp.tool()
def list_products(
    search: str = None,
    page_size: int = 50,
) -> str:
    """
    List available products in Apollo.

    Products represent deployable software (Helm charts, assets) that can be
    installed as entities on environments. Use this to discover product IDs
    for use with install_entity.

    Args:
        search: Optional substring to filter products by name or ID (case-insensitive).
        page_size: Number of products to return (default 50, max 200).
    """
    if page_size > 200:
        page_size = 200

    query = """
    query ListProducts($pageSize: Int!) {
        apollo {
            products(pageSize: $pageSize) {
                products {
                    rid
                    productId
                    latestProductRelease {
                        mavenCoordinate
                        releaseChannels { name }
                    }
                }
                nextPageToken
            }
        }
    }
    """
    data, error = _apollo_graphql_query(query, {"pageSize": page_size})
    if error:
        return f"Error: {error}"

    products = data.get("apollo", {}).get("products", {}).get("products", [])

    if search:
        s = search.lower()
        products = [
            p for p in products
            if s in (p.get("productId") or "").lower()
        ]

    if not products:
        return f"No products found" + (f" matching '{search}'" if search else "") + "."

    lines = [f"Products ({len(products)} found)", "=" * 50, ""]

    for p in products:
        product_id = p.get("productId", "")
        latest = p.get("latestProductRelease") or {}
        maven = latest.get("mavenCoordinate", "")
        channels = [c.get("name", "") for c in (latest.get("releaseChannels") or [])]

        lines.append(f"  {product_id}")
        if maven:
            lines.append(f"    Latest:   {maven}")
        if channels:
            lines.append(f"    Channels: {', '.join(channels)}")
        lines.append("")

    return "\n".join(lines)


@mcp.tool()
def compare_product_versions(
    product_id: str,
    environments: str,
) -> str:
    """
    Compare what version of a product is installed across multiple environments.

    Shows the installed version, rollout status, health, and release channel for
    a given product on each specified environment. Useful for understanding deployment
    state across your fleet.

    Args:
        product_id: Product maven coordinate in "groupId:artifactId" format
                    (e.g. "com.edgescaleai-cube:esai-platform-z"). Can also be a
                    substring to match (e.g. "platform-z").
        environments: Comma-separated list of environment names
                      (e.g. "pep-onlogicmc610-4, joel-cube, staging-int")
    """
    env_names = [e.strip() for e in environments.split(",") if e.strip()]
    if not env_names:
        return "Error: provide at least one environment name."

    search = product_id.lower()
    lines = [f"Product Version Comparison: {product_id}", "=" * 60, ""]

    for env_name in env_names:
        env_info, error = _resolve_environment(env_name)
        if error:
            lines.append(f"  {env_name}: ERROR - {error}")
            lines.append("")
            continue

        env_id = env_info["id"]
        display_name = env_info.get("name", env_name)

        query = f"""
        query EnvVersions($id: String!) {{
            apollo {{
                environmentById(id: $id) {{
                    name
                    defaultReleaseChannel
                    entities(pageSize: 200) {{
                        entities {{
                            displayName
                            entityLocator {{
                                ... on ApolloEntityLocator_ApolloEntity {{ entityName }}
                            }}
                            product {{ productId }}
                            reportedStatus {{
                                version
                                rolloutStatus
                                configStatus
                                healthStatus
                            }}
                            declaredState {{ releaseChannel }}
                            lifecycleStatus
                        }}
                    }}
                }}
            }}
        }}
        """
        data, error = _apollo_graphql_query(query, {"id": env_id})
        if error:
            lines.append(f"  {display_name}: ERROR - {error}")
            lines.append("")
            continue

        env = data.get("apollo", {}).get("environmentById")
        if not env:
            lines.append(f"  {display_name}: environment not found")
            lines.append("")
            continue

        default_channel = env.get("defaultReleaseChannel", "")
        entities = env.get("entities", {}).get("entities", [])

        # Find matching entities
        matched = [
            e for e in entities
            if search in (e.get("product", {}).get("productId") or "").lower()
        ]

        if not matched:
            lines.append(f"  {display_name}:")
            lines.append(f"    Product not installed")
            lines.append(f"    Default channel: {default_channel or '(none)'}")
            lines.append("")
            continue

        for entity in matched:
            ename = entity.get("displayName") or entity.get("entityLocator", {}).get("entityName", "?")
            pid = entity.get("product", {}).get("productId", "")
            status = entity.get("reportedStatus") or {}
            version = status.get("version", "(no report)")
            rollout = status.get("rolloutStatus", "?")
            health = status.get("healthStatus") or "?"
            config = status.get("configStatus", "?")
            channel = (entity.get("declaredState") or {}).get("releaseChannel") or default_channel or "?"
            lifecycle = entity.get("lifecycleStatus", "?")

            rollout_icon = _STATUS_ICONS.get(rollout, "?")
            health_icon = _STATUS_ICONS.get(health, "?")

            lines.append(f"  {display_name}:")
            lines.append(f"    Entity:    {ename}")
            lines.append(f"    Product:   {pid}")
            lines.append(f"    Version:   {version}")
            lines.append(f"    Health:    {health_icon} {health}")
            lines.append(f"    Rollout:   {rollout_icon} {rollout}")
            lines.append(f"    Config:    {config}")
            lines.append(f"    Channel:   {channel}")
            lines.append(f"    Lifecycle: {lifecycle}")
            lines.append("")

    return "\n".join(lines)


@mcp.tool()
def list_release_channels(
    search: str = None,
    include_env_specific: bool = False,
) -> str:
    """
    List available Apollo release channels.

    Release channels control which product versions are available for deployment
    to environments. Common channels include DEV, INT, PRE-PROD, PROD, etc.

    By default, only shows ordinary (non-environment-specific) channels.

    Args:
        search: Optional substring to filter channels by name (case-insensitive).
        include_env_specific: If true, also show environment-specific channels
                              (there are hundreds of these, one per environment).
    """
    try:
        cmd = ["apollo-cli", "release-channel", "list", "-o", "json"]
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        if result.returncode != 0:
            return f"Error listing release channels: {result.stderr}"

        data = json.loads(result.stdout)
        channels = data.get("releaseChannels", [])
    except subprocess.TimeoutExpired:
        return "Error: Command timed out"
    except json.JSONDecodeError:
        return f"Error parsing response: {result.stdout[:200]}"
    except FileNotFoundError:
        return "Error: apollo-cli not found. Install it first."

    # Filter by type
    if not include_env_specific:
        channels = [
            c for c in channels
            if c.get("metadata", {}).get("channelType", {}).get("type") != "environmentSpecificChannel"
        ]

    # Filter by search
    if search:
        s = search.lower()
        channels = [c for c in channels if s in c.get("name", "").lower()]

    if not channels:
        return "No release channels found" + (f" matching '{search}'" if search else "") + "."

    lines = [f"Release Channels ({len(channels)})", "=" * 50, ""]

    for c in channels:
        name = c.get("name", "")
        rid = c.get("rid", "")
        meta = c.get("metadata", {})
        desc = meta.get("description", "")
        chan_type = meta.get("channelType", {}).get("type", "")
        archived = meta.get("archived", {}).get("type", "")
        bundling = meta.get("bundlingStrategy", {}).get("type", "")

        label_reqs = meta.get("labelRequirements", {}).get("requirements", [])

        lines.append(f"  {name}")
        lines.append(f"    RID:       {rid}")
        if desc:
            lines.append(f"    Desc:      {desc}")
        if chan_type and chan_type != "ordinary":
            lines.append(f"    Type:      {chan_type}")
        if archived == "isArchived":
            lines.append(f"    Archived:  yes")
        if bundling:
            lines.append(f"    Bundling:  {bundling}")
        if label_reqs:
            labels_str = ", ".join(
                f"{r.get('labelId', '?')}={r.get('acceptedValues', ['*'])}"
                for r in label_reqs
            )
            lines.append(f"    Labels:    {labels_str}")
        lines.append("")

    return "\n".join(lines)


@mcp.tool()
def get_product_releases(
    product_id: str,
    page_size: int = 10,
) -> str:
    """
    Get release history for a product, showing versions and their release channels.

    Shows all published releases for a product with their maven coordinates,
    release channel memberships, and RIDs. Use this to find the release RID
    needed for add_product_to_release_channel.

    Args:
        product_id: Product maven coordinate in "groupId:artifactId" format
                    (e.g. "com.edgescaleai-cube:esai-platform-z")
        page_size: Number of releases to return (default 10, max 50).
    """
    if page_size > 50:
        page_size = 50

    query = """
    query ProductReleases($productId: String!, $pageSize: Int!) {
        apollo {
            productByProductIdV2(productId: $productId) {
                rid
                productId
                labels { labelId labelValue }
                productReleases(pageSize: $pageSize) {
                    productReleases {
                        rid
                        mavenCoordinate
                        created
                        releaseChannels { name }
                        recallStatus { __typename }
                        labels { labelId labelValue }
                    }
                }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(query, {
        "productId": product_id,
        "pageSize": page_size,
    })
    if error:
        return f"Error: {error}"

    product = data.get("apollo", {}).get("productByProductIdV2")
    if not product:
        return f"Product not found: {product_id}"

    releases = product.get("productReleases", {}).get("productReleases", [])
    product_labels = product.get("labels", [])

    lines = [
        f"Product: {product.get('productId', product_id)}",
        "=" * 60,
        f"  RID: {product.get('rid', '')}",
    ]

    if product_labels:
        labels_str = ", ".join(f"{l['labelId']}={l['labelValue']}" for l in product_labels)
        lines.append(f"  Labels: {labels_str}")

    lines.append(f"  Releases: {len(releases)}")
    lines.append("")

    for r in releases:
        maven = r.get("mavenCoordinate", "")
        rid = r.get("rid", "")
        created = r.get("created", "")[:19].replace("T", " ") if r.get("created") else ""
        channels = sorted([c.get("name", "") for c in r.get("releaseChannels", [])])
        recall_type = (r.get("recallStatus") or {}).get("__typename", "")
        recall = "RECALLED" if "Recalled" in recall_type else ""
        release_labels = r.get("labels", [])

        # Extract version from maven coordinate
        version = maven.split(":")[-1] if ":" in maven else maven

        lines.append(f"  {version}")
        lines.append(f"    Coordinate: {maven}")
        lines.append(f"    RID:        {rid}")
        if created:
            lines.append(f"    Created:    {created}")
        if channels:
            lines.append(f"    Channels:   {', '.join(channels)}")
        else:
            lines.append(f"    Channels:   (none)")
        if recall and recall != "NOT_RECALLED":
            lines.append(f"    Recall:     {recall}")
        if release_labels:
            labels_str = ", ".join(f"{l['labelId']}={l['labelValue']}" for l in release_labels)
            lines.append(f"    Labels:     {labels_str}")
        lines.append("")

    return "\n".join(lines)


@mcp.tool()
def add_product_to_release_channel(
    product_id: str,
    version: str,
    channels: str,
    rationale: str = "",
) -> str:
    """
    Add a product release to one or more release channels.

    Promotes a specific version of a product to the given release channels,
    making it available for deployment to environments subscribed to those channels.

    Use get_product_releases to find the version and current channel memberships.

    Args:
        product_id: Product maven coordinate in "groupId:artifactId" format
                    (e.g. "com.edgescaleai-cube:esai-platform-z")
        version: The version to promote (e.g. "0.5.4"). Combined with product_id
                 to form the full maven coordinate "groupId:artifactId:version".
        channels: Comma-separated list of release channel names to add to
                  (e.g. "INT, PRE-PROD" or "PROD")
        rationale: Reason for the promotion (defaults to auto-generated message).
    """
    channel_names = [c.strip() for c in channels.split(",") if c.strip()]
    if not channel_names:
        return "Error: provide at least one channel name."

    maven_coordinate = f"{product_id}:{version}"

    # Find the product release RID
    query = """
    query FindRelease($coord: String!) {
        apollo {
            productReleaseByCoordinate(mavenCoordinate: $coord) {
                rid
                mavenCoordinate
                releaseChannels { name }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(query, {"coord": maven_coordinate})
    if error:
        return f"Error finding release: {error}"

    release = data.get("apollo", {}).get("productReleaseByCoordinate")
    if not release:
        return f"Release not found: {maven_coordinate}\n\nUse get_product_releases to see available versions."

    release_rid = release.get("rid", "")
    current_channels = sorted([c.get("name", "") for c in release.get("releaseChannels", [])])

    if not rationale:
        rationale = f"Promoting {maven_coordinate} to {', '.join(channel_names)} via MCP"

    mutation = """
    mutation AddToChannels($rid: RID!, $channels: [String!]!, $rationale: String!) {
        apollo {
            addProductReleaseToChannels(rid: $rid, releaseChannelIds: $channels, rationale: $rationale) {
                rid
                mavenCoordinate
                releaseChannels { name }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(mutation, {
        "rid": release_rid,
        "channels": channel_names,
        "rationale": rationale,
    })
    if error:
        return f"Error adding to channels: {error}"

    result = data.get("apollo", {}).get("addProductReleaseToChannels", {})
    new_channels = sorted([c.get("name", "") for c in result.get("releaseChannels", [])])
    added = set(new_channels) - set(current_channels)

    lines = [
        f"Product release promoted",
        "=" * 50,
        f"  Release:   {result.get('mavenCoordinate', maven_coordinate)}",
        f"  RID:       {result.get('rid', release_rid)}",
        f"  Rationale: {rationale}",
        "",
        f"  Before:    {', '.join(current_channels) if current_channels else '(none)'}",
        f"  After:     {', '.join(new_channels) if new_channels else '(none)'}",
        f"  Added:     {', '.join(sorted(added)) if added else '(none - already on all requested channels)'}",
    ]

    return "\n".join(lines)


@mcp.tool()
def remove_product_from_release_channel(
    product_id: str,
    version: str,
    channels: str,
    rationale: str = "",
) -> str:
    """
    Remove a product release from one or more release channels.

    Demotes a specific version by removing it from the given release channels.
    Environments subscribed to those channels will no longer receive this version.

    Use get_product_releases to see current channel memberships.

    Args:
        product_id: Product maven coordinate in "groupId:artifactId" format
                    (e.g. "com.edgescaleai-cube:esai-platform-z")
        version: The version to demote (e.g. "0.5.4").
        channels: Comma-separated list of release channel names to remove from
                  (e.g. "INT, PRE-PROD")
        rationale: Reason for the demotion (defaults to auto-generated message).
    """
    channel_names = [c.strip() for c in channels.split(",") if c.strip()]
    if not channel_names:
        return "Error: provide at least one channel name."

    maven_coordinate = f"{product_id}:{version}"

    # Find the product release RID
    query = """
    query FindRelease($coord: String!) {
        apollo {
            productReleaseByCoordinate(mavenCoordinate: $coord) {
                rid
                mavenCoordinate
                releaseChannels { name }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(query, {"coord": maven_coordinate})
    if error:
        return f"Error finding release: {error}"

    release = data.get("apollo", {}).get("productReleaseByCoordinate")
    if not release:
        return f"Release not found: {maven_coordinate}\n\nUse get_product_releases to see available versions."

    release_rid = release.get("rid", "")
    current_channels = sorted([c.get("name", "") for c in release.get("releaseChannels", [])])

    if not rationale:
        rationale = f"Removing {maven_coordinate} from {', '.join(channel_names)} via MCP"

    mutation = """
    mutation RemoveFromChannels($rid: RID!, $channels: [String!]!, $rationale: String!) {
        apollo {
            deleteProductReleaseFromChannels(rid: $rid, releaseChannels: $channels, rationale: $rationale) {
                rid
                mavenCoordinate
                releaseChannels { name }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(mutation, {
        "rid": release_rid,
        "channels": channel_names,
        "rationale": rationale,
    })
    if error:
        return f"Error removing from channels: {error}"

    result = data.get("apollo", {}).get("deleteProductReleaseFromChannels", {})
    new_channels = sorted([c.get("name", "") for c in result.get("releaseChannels", [])])
    removed = set(current_channels) - set(new_channels)

    lines = [
        f"Product release demoted",
        "=" * 50,
        f"  Release:   {result.get('mavenCoordinate', maven_coordinate)}",
        f"  RID:       {result.get('rid', release_rid)}",
        f"  Rationale: {rationale}",
        "",
        f"  Before:    {', '.join(current_channels) if current_channels else '(none)'}",
        f"  After:     {', '.join(new_channels) if new_channels else '(none)'}",
        f"  Removed:   {', '.join(sorted(removed)) if removed else '(none - was not on requested channels)'}",
    ]

    return "\n".join(lines)


@mcp.tool()
def set_label_on_product(
    product_id: str,
    label_id: str,
    label_value: str,
) -> str:
    """
    Set a label on a product.

    Labels are used by release channels with label requirements. Some channels
    require products to have specific labels before releases can be added.
    Labels are also useful for organizing and filtering products.

    Use get_product_releases to see current labels on a product.

    Args:
        product_id: Product maven coordinate in "groupId:artifactId" format
                    (e.g. "com.edgescaleai-cube:esai-platform-z")
        label_id: The label ID to set (e.g. "customer", "tier", "approved")
        label_value: The value for the label (e.g. "lear", "production", "true")
    """
    # Get product RID
    query = """
    query GetProduct($productId: String!) {
        apollo {
            productByProductIdV2(productId: $productId) {
                rid
                productId
                labels { labelId labelValue }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(query, {"productId": product_id})
    if error:
        return f"Error finding product: {error}"

    product = data.get("apollo", {}).get("productByProductIdV2")
    if not product:
        return f"Product not found: {product_id}"

    product_rid = product.get("rid", "")
    current_labels = product.get("labels", [])

    mutation = """
    mutation SetLabel($rid: RID!, $labelId: String!, $labelValue: String!) {
        apollo {
            setLabelValueForResource(rid: $rid, labelId: $labelId, labelValue: $labelValue) {
                labelId
                labelValue
            }
        }
    }
    """
    data, error = _apollo_graphql_query(mutation, {
        "rid": product_rid,
        "labelId": label_id,
        "labelValue": label_value,
    })
    if error:
        return f"Error setting label: {error}"

    result_labels = data.get("apollo", {}).get("setLabelValueForResource", [])

    lines = [
        f"Label set on product",
        "=" * 50,
        f"  Product:   {product_id}",
        f"  RID:       {product_rid}",
        f"  Label:     {label_id} = {label_value}",
        "",
        "  Previous labels:",
    ]
    if current_labels:
        for l in current_labels:
            lines.append(f"    {l['labelId']} = {l['labelValue']}")
    else:
        lines.append("    (none)")

    lines.append("")
    lines.append("  Current labels:")
    if result_labels:
        for l in result_labels:
            lines.append(f"    {l['labelId']} = {l['labelValue']}")
    else:
        lines.append("    (none)")

    return "\n".join(lines)


@mcp.tool()
def remove_label_from_product(
    product_id: str,
    label_id: str,
) -> str:
    """
    Remove a label from a product.

    Args:
        product_id: Product maven coordinate in "groupId:artifactId" format
                    (e.g. "com.edgescaleai-cube:esai-platform-z")
        label_id: The label ID to remove (e.g. "customer", "tier")
    """
    # Get product RID
    query = """
    query GetProduct($productId: String!) {
        apollo {
            productByProductIdV2(productId: $productId) {
                rid
                productId
                labels { labelId labelValue }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(query, {"productId": product_id})
    if error:
        return f"Error finding product: {error}"

    product = data.get("apollo", {}).get("productByProductIdV2")
    if not product:
        return f"Product not found: {product_id}"

    product_rid = product.get("rid", "")
    current_labels = product.get("labels", [])

    mutation = """
    mutation RemoveLabel($rid: RID!, $labelId: String!) {
        apollo {
            deleteLabelValueForResource(rid: $rid, labelId: $labelId)
        }
    }
    """
    data, error = _apollo_graphql_query(mutation, {
        "rid": product_rid,
        "labelId": label_id,
    })
    if error:
        return f"Error removing label: {error}"

    success = data.get("apollo", {}).get("deleteLabelValueForResource", False)

    lines = [
        f"Label removed from product",
        "=" * 50,
        f"  Product:   {product_id}",
        f"  RID:       {product_rid}",
        f"  Removed:   {label_id}",
        f"  Success:   {success}",
        "",
        "  Previous labels:",
    ]
    if current_labels:
        for l in current_labels:
            marker = " (removed)" if l['labelId'] == label_id else ""
            lines.append(f"    {l['labelId']} = {l['labelValue']}{marker}")
    else:
        lines.append("    (none)")

    return "\n".join(lines)


@mcp.tool()
def list_change_requests(
    environment: str = None,
    status: str = None,
    page_size: int = 10,
) -> str:
    """
    List Apollo change requests, optionally filtered by environment and status.

    Change requests are created when entities are installed, uninstalled, or
    have their config updated. Use this to find CR RIDs for review_change_request.

    Args:
        environment: Optional environment name to filter by (e.g. "pep-onlogicmc610-4").
                     If omitted, shows CRs across all environments.
        status: Optional status filter. One of: PENDING_APPROVALS, APPROVED,
                APPROVED_APPLYING, APPROVED_MERGED, CANCELLED, REJECTED, ERROR.
        page_size: Number of results (default 10, max 50).
    """
    if page_size > 50:
        page_size = 50

    cr_filter = {}

    if environment:
        env_info, error = _resolve_environment(environment)
        if error:
            return f"Error resolving environment: {error}"
        cr_filter["environmentIds"] = [env_info["id"]]

    if status:
        cr_filter["status"] = status.upper()

    query = """
    query ListCRs($filter: ApolloChangeRequestFilter!, $pageSize: Int!) {
        apollo {
            changeRequests(filter: $filter, pageSize: $pageSize) {
                changeRequests {
                    rid
                    title
                    requestStatus
                    updatedAt
                    description
                    created { user { username } time }
                }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(query, {
        "filter": cr_filter,
        "pageSize": page_size,
    })
    if error:
        return f"Error: {error}"

    crs = data.get("apollo", {}).get("changeRequests", {}).get("changeRequests", [])

    if not crs:
        return "No change requests found" + (f" for {environment}" if environment else "") + (f" with status {status}" if status else "") + "."

    lines = [f"Change Requests ({len(crs)})", "=" * 60, ""]

    for cr in crs:
        updated = cr.get("updatedAt", "")[:19].replace("T", " ") if cr.get("updatedAt") else ""
        created_by = ((cr.get("created") or {}).get("user") or {}).get("username", "")
        created_at = ((cr.get("created") or {}).get("time") or "")[:19].replace("T", " ")
        status_val = cr.get("requestStatus", "?")

        icon = {"APPROVED": "✓", "APPROVED_MERGED": "✓", "APPROVED_APPLYING": "⟳",
                "PENDING_APPROVALS": "⏳", "CANCELLED": "✗", "REJECTED": "✗", "ERROR": "✗"}.get(status_val, "?")

        lines.append(f"  {icon} {status_val}")
        lines.append(f"    Title:   {cr.get('title', '')}")
        lines.append(f"    RID:     {cr.get('rid', '')}")
        if created_at:
            lines.append(f"    Created: {created_at}")
        if updated:
            lines.append(f"    Updated: {updated}")
        if created_by:
            lines.append(f"    Author:  {created_by}")
        lines.append("")

    return "\n".join(lines)


@mcp.tool()
def review_change_request(
    rid: str,
    action: str,
    comment: str = "",
) -> str:
    """
    Review (approve, reject, or cancel) an Apollo change request.

    Change requests are created for entity installs, uninstalls, config updates,
    and environment deletions. Use list_change_requests to find the CR RID.

    Cancelling a pending change request effectively cancels the planned operation
    (install, uninstall, config change).

    Args:
        rid: The change request RID (e.g. "ri.approval.1.request.xxx")
        action: One of "approve", "reject", or "cancel"
        comment: Required for reject (the rationale). Optional for approve.
    """
    action = action.lower().strip()
    if action not in ("approve", "reject", "cancel"):
        return f"Error: action must be 'approve', 'reject', or 'cancel'. Got: {action}"

    if action == "reject" and not comment:
        return "Error: comment (rationale) is required when rejecting a change request."

    # Build the review input
    if action == "approve":
        review = {"approve": {"comment": comment or "Approved via MCP"}}
    elif action == "reject":
        review = {"reject": {"rationale": comment}}
    else:  # cancel
        review = {"cancel": {"dummy": True}}

    mutation = """
    mutation ReviewCR($rid: RID!, $review: ApolloChangeRequestReview!) {
        apollo {
            reviewChangeRequest(rid: $rid, review: $review) {
                rid
                title
                requestStatus
            }
        }
    }
    """
    data, error = _apollo_graphql_query(mutation, {
        "rid": rid,
        "review": review,
    })
    if error:
        return f"Error reviewing change request: {error}"

    cr = data.get("apollo", {}).get("reviewChangeRequest", {})

    lines = [
        f"Change request {'cancelled' if action == 'cancel' else action + 'd'}",
        "=" * 50,
        f"  Title:   {cr.get('title', '')}",
        f"  RID:     {cr.get('rid', rid)}",
        f"  Status:  {cr.get('requestStatus', '?')}",
        f"  Action:  {action}",
    ]
    if comment:
        lines.append(f"  Comment: {comment}")

    return "\n".join(lines)


@mcp.tool()
def enforce_entity_config(
    environment: str,
    entity_name: str,
) -> str:
    """
    Force config re-enforcement on an entity.

    Re-submits the entity's current config overrides, which triggers Apollo to
    create a new "enforce config" plan. Useful when an entity's config has drifted
    or a previous enforcement failed.

    Args:
        environment: Environment name or RID (e.g. "pep-onlogicmc610-4")
        entity_name: Entity display name or entity name (substring match)
    """
    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error resolving environment: {error}"
    env_id = env_info["id"]
    env_name = env_info.get("name", environment)

    # Find entity with current config
    find_query = """
    query FindEntity($id: String!) {
        apollo {
            environmentById(id: $id) {
                namespaceId
                entities(pageSize: 200) {
                    entities {
                        id
                        displayName
                        entityLocator {
                            ... on ApolloEntityLocator_ApolloEntity { entityName }
                        }
                        reportedConfigOverrides {
                            ... on ApolloEntityReportedConfigOverrides_Default {
                                overrides
                            }
                        }
                        reportedStatus { version configStatus }
                    }
                }
            }
        }
    }
    """
    data, error = _apollo_graphql_query(find_query, {"id": env_id})
    if error:
        return f"Error querying entities: {error}"

    env_data = data.get("apollo", {}).get("environmentById", {})
    namespace_id = env_data.get("namespaceId", "")
    entities = env_data.get("entities", {}).get("entities", [])

    search = entity_name.lower()
    matched = [
        e for e in entities
        if search in (e.get("displayName") or "").lower()
        or search in (e.get("entityLocator", {}).get("entityName") or "").lower()
    ]

    if not matched:
        names = sorted(set(
            e.get("displayName") or e.get("entityLocator", {}).get("entityName", "") or e.get("id", "")
            for e in entities
        ))
        return f"Entity '{entity_name}' not found in {env_name}.\n\nAvailable entities:\n" + "\n".join(f"  - {n}" for n in names)

    if len(matched) > 1:
        exact = [e for e in matched if (e.get("displayName") or "").lower() == search or (e.get("entityLocator", {}).get("entityName") or "").lower() == search]
        if len(exact) == 1:
            matched = exact
        else:
            names = [e.get("displayName") or e.get("id") for e in matched]
            return f"Multiple entities match '{entity_name}':\n" + "\n".join(f"  - {n}" for n in names) + "\n\nPlease be more specific."

    entity = matched[0]
    apollo_entity_id = entity.get("id", "")
    entity_display = entity.get("displayName") or entity.get("entityLocator", {}).get("entityName", "unknown")
    current_overrides = (entity.get("reportedConfigOverrides") or {}).get("overrides", {})
    config_status = ((entity.get("reportedStatus") or {}).get("configStatus", "?"))

    # Re-submit the same config to trigger enforcement
    config_content = json.dumps({"0.0.0": {"overrides": current_overrides}})

    mutation = """
    mutation EnforceConfig($update: ApolloChangeRequestUpdateConfig!) {
        apollo {
            updateConfigByResource(update: $update) {
                rid
                requestStatus
                title
            }
        }
    }
    """

    data, error = _apollo_graphql_query(mutation, {
        "update": {
            "type": {"entity": {"apolloEntityId": apollo_entity_id}},
            "content": config_content,
            "namespaceId": namespace_id,
        },
    })
    if error:
        return f"Error enforcing config: {error}"

    cr = data.get("apollo", {}).get("updateConfigByResource", {})

    lines = [
        f"Config enforcement triggered on {env_name}",
        "=" * 50,
        f"  Entity:         {entity_display}",
        f"  Entity ID:      {apollo_entity_id}",
        f"  Config Status:  {config_status} (before)",
        "",
        f"  Change Request: {cr.get('requestStatus', '?')}",
        f"  CR RID:         {cr.get('rid', '')}",
        f"  CR Title:       {cr.get('title', '')}",
        "",
        "  Current overrides re-submitted:",
    ]
    if current_overrides:
        for k, v in current_overrides.items():
            val_str = json.dumps(v) if isinstance(v, (dict, list)) else str(v)
            if len(val_str) > 80:
                val_str = val_str[:77] + "..."
            lines.append(f"    {k}: {val_str}")
    else:
        lines.append("    (empty - no overrides)")

    lines.append("")
    lines.append("Use plan_details to monitor enforcement progress.")

    return "\n".join(lines)


@mcp.tool()
def reset_environment_agents(
    environment: str,
) -> str:
    """
    Reset all agents on an Apollo environment, forcing full re-evaluation.

    This triggers agents to re-evaluate all entities on the environment, which
    effectively forces version enforcement and config re-synchronization. Use
    this when entities are stuck or need to re-attempt failed installations.

    WARNING: This resets ALL agents on the environment, which will cause
    all entities to be re-evaluated simultaneously.

    Args:
        environment: Environment name or RID (e.g. "pep-onlogicmc610-4")
    """
    env_info, error = _resolve_environment(environment)
    if error:
        return f"Error resolving environment: {error}"
    env_id = env_info["id"]
    env_name = env_info.get("name", environment)

    mutation = """
    mutation ResetAgents($envId: String!) {
        apollo {
            resetAgentsByEnvironmentId(apolloEnvironmentId: $envId)
        }
    }
    """
    data, error = _apollo_graphql_query(mutation, {"envId": env_id})
    if error:
        return f"Error resetting agents: {error}"

    result = data.get("apollo", {}).get("resetAgentsByEnvironmentId", [])

    lines = [
        f"Agents reset on {env_name}",
        "=" * 50,
        f"  Environment: {env_name}",
        f"  ID:          {env_id}",
        "",
        "All agents will re-evaluate entity state.",
        "Use entity_health to monitor progress.",
    ]

    return "\n".join(lines)


def main():
    mcp.run()


if __name__ == "__main__":
    main()
