import logging
import os
import re
import shutil
import sys
from typing import Optional

from backend.apps.tools_lib.models import ToolDefinition
from backend.apps.tools_lib.oauth_config import OPENSWARM_OAUTH_BASE_URL

logger = logging.getLogger(__name__)


def _sanitize_server_name(name: str) -> str:
    """Convert a tool name into a valid MCP server identifier (alphanumeric + hyphens)."""
    return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")


def _extra_bin_dirs() -> list[str]:
    """Well-known user-local bin directories that may not be on PATH in packaged apps."""
    home = os.path.expanduser("~")
    # Bundled uv-bin (ships uvx for non-dev users)
    _backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    dirs = [
        os.path.join(_backend, "uv-bin"),
        os.path.join(home, ".bun", "bin"),
        os.path.join(home, ".cargo", "bin"),
        os.path.join(home, ".local", "bin"),
        os.path.join(home, ".volta", "bin"),
        "/opt/homebrew/bin",
        "/usr/local/bin",
    ]
    # nvm: pick the newest installed node version
    nvm_node = os.path.join(home, ".nvm", "versions", "node")
    try:
        if os.path.isdir(nvm_node):
            versions = sorted(os.listdir(nvm_node), reverse=True)
            if versions:
                dirs.insert(0, os.path.join(nvm_node, versions[0], "bin"))
    except OSError:
        pass
    # fnm
    fnm_bin = os.path.join(home, "Library", "Application Support", "fnm", "aliases", "default", "bin")
    if os.path.isdir(fnm_bin):
        dirs.insert(0, fnm_bin)
    return dirs


def _resolve_command(command: str) -> str | None:
    """Find a command on PATH, falling back to common user-local bin directories
    and bundled binaries (uv-bin for uvx/uv)."""
    found = shutil.which(command)
    if found:
        return found
    # Windows binaries need an extension. shutil.which() handles PATHEXT for
    # PATH lookups, but we manually scan _extra_bin_dirs below; replicate
    # the suffix probing here so `uvx` finds `uvx.exe`, etc.
    if sys.platform == "win32":
        suffixes = [""] + os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").lower().split(os.pathsep)
    else:
        suffixes = [""]
    def _probe(directory: str) -> str | None:
        for suffix in suffixes:
            candidate = os.path.join(directory, command + suffix)
            if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
                return candidate
        return None
    for d in _extra_bin_dirs():
        hit = _probe(d)
        if hit:
            return hit
    # Check bundled uv-bin directory (ships uv/uvx for non-dev users)
    _backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    return _probe(os.path.join(_backend, "uv-bin"))


def _augmented_path() -> str:
    """Return PATH with extra bin dirs prepended (for child process environments)."""
    extra = [d for d in _extra_bin_dirs() if os.path.isdir(d)]
    current = os.environ.get("PATH", "")
    seen: set[str] = set()
    parts: list[str] = []
    for p in extra + current.split(os.pathsep):
        if p and p not in seen:
            seen.add(p)
            parts.append(p)
    return os.pathsep.join(parts)


def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
    """Build the claude_agent_sdk mcp_servers config entry for a tool.

    Returns None if the tool cannot be configured (e.g. missing data).
    """
    if not tool.mcp_config:
        return None

    config: dict = dict(tool.mcp_config)

    if tool.credentials:
        if config.get("type") in ("http", "sse"):
            headers = config.setdefault("headers", {})
            for key, val in tool.credentials.items():
                if key.lower() in ("authorization", "api_key", "api-key"):
                    headers.setdefault("Authorization", f"Bearer {val}")
        else:
            env = config.setdefault("env", {})
            env.update(tool.credentials)

    if tool.oauth_tokens.get("access_token"):
        if config.get("type") in ("http", "sse"):
            headers = config.setdefault("headers", {})
            headers["Authorization"] = f"Bearer {tool.oauth_tokens['access_token']}"
        else:
            env = config.setdefault("env", {})
            env["OAUTH_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"]
            if tool.name.lower() == "notion":
                env["NOTION_TOKEN"] = tool.oauth_tokens["access_token"]
            if tool.name.lower() == "hubspot":
                env["PRIVATE_APP_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"]
            if tool.oauth_tokens.get("refresh_token"):
                env["GOOGLE_WORKSPACE_REFRESH_TOKEN"] = tool.oauth_tokens["refresh_token"]
            # google_workspace_mcp's gauth.py hardcodes token_uri to
            # https://oauth2.googleapis.com/token and refreshes using the
            # local CLIENT_ID/SECRET on every API call. The OAuth flow
            # itself runs through the cloud's rotation pool, so the
            # refresh_token is bound to whichever pool slot minted it,
            # not the single client baked into the DMG. Mismatch -> Google
            # returns unauthorized_client. We point token_uri at a local
            # proxy that forwards the refresh to our cloud's pool-aware
            # /api/oauth/google/refresh endpoint; CLIENT_ID/SECRET become
            # unused placeholders (gauth.py only validates non-empty).
            _port = os.environ.get("OPENSWARM_PORT", "8324")
            env["GOOGLE_WORKSPACE_TOKEN_URI"] = (
                f"http://127.0.0.1:{_port}/api/tools/google-oauth-token"
            )
            env.setdefault("GOOGLE_WORKSPACE_CLIENT_ID", "openswarm-proxy")
            env.setdefault("GOOGLE_WORKSPACE_CLIENT_SECRET", "openswarm-proxy")

    # Google Workspace MCP: redirect spawn through our shim that
    # monkey-patches gauth.get_credentials before the worker registers
    # tools, so token_uri points at our local proxy. Stays a stdio
    # subprocess; google-workspace-mcp gets installed into uv's
    # ephemeral env via --with, same way the upstream entry-point
    # invocation used to do it.
    if tool.name.lower() == "google workspace" and config.get("type") == "stdio":
        shim_path = os.path.join(
            os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
            "google_workspace_mcp_shim",
            "run.py",
        )
        config["command"] = "uv"
        config["args"] = ["run", "--with", "google-workspace-mcp", "python", shim_path]

    # Discord MCP runs as a small Python shim (backend.apps.discord_mcp_shim).
    # We pass install_id + base URL via env so the shim subprocess doesn't
    # need to import backend.config.* itself.
    if tool.name.lower() == "discord" and config.get("type") == "stdio":
        from backend.config.install_id import get_install_id
        env = config.setdefault("env", {})
        env["OPENSWARM_OAUTH_BASE_URL"] = OPENSWARM_OAUTH_BASE_URL
        env["OPENSWARM_INSTALL_ID"] = get_install_id()
        # Pass the authorized guild IDs so the shim can scope-enforce.
        guild_ids = [g.get("id", "") for g in (tool.oauth_tokens.get("guilds") or []) if g.get("id")]
        if guild_ids:
            env["OPENSWARM_DISCORD_GUILD_IDS"] = ",".join(guild_ids)
        # The shim runs as a subprocess and needs to import
        # `backend.apps.discord_mcp_shim`; set PYTHONPATH to the project
        # root (parent of the backend/ dir) so that import resolves.
        _project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
        existing_pp = env.get("PYTHONPATH") or os.environ.get("PYTHONPATH", "")
        env["PYTHONPATH"] = (_project_root + os.pathsep + existing_pp) if existing_pp else _project_root

    # Microsoft 365 MCP: use a stable token cache path shared across process spawns
    if tool.name.lower() == "microsoft 365" and config.get("type") == "stdio":
        env = config.setdefault("env", {})
        cache_dir = os.path.join(os.path.expanduser("~"), ".openswarm")
        os.makedirs(cache_dir, exist_ok=True)
        env["MS365_MCP_TOKEN_CACHE_PATH"] = os.path.join(cache_dir, "ms365-token-cache.json")
        env["MS365_MCP_SELECTED_ACCOUNT_PATH"] = os.path.join(cache_dir, "ms365-selected-account.json")

    if config.get("type") == "stdio":
        if config.get("command"):
            # `python` (no version suffix) doesn't exist on a stock macOS,
            # so a tool config that asks for "python" silently fails to
            # spawn; Claude Agent SDK then exposes zero tools from that
            # MCP. We resolve to the actual interpreter running the
            # backend (sys.executable), which is guaranteed to exist and
            # have backend modules importable. `python3` and absolute
            # paths pass through unchanged.
            if config["command"] == "python":
                resolved_python = sys.executable or shutil.which("python3") or shutil.which("python")
                if resolved_python:
                    config["command"] = resolved_python
            # Check for bundled npm MCP servers; use Electron's Node.js instead of npx
            if config["command"] in ("npx", "bunx"):
                pkg_name = next((a for a in (config.get("args") or []) if not a.startswith("-")), None)
                if pkg_name:
                    _backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
                    electron_path = os.environ.get("OPENSWARM_ELECTRON_PATH")
                    # Two bundle layouts in mcp-bundles/, checked in priority order:
                    #
                    #  1. Multi-file bundle dir: mcp-bundles/<safe>/dist/index.js
                    #     Used when the SDK reads sibling files at runtime.
                    #     Examples: @softeria/ms-365-mcp-server reads
                    #     ../package.json for --version and dist/endpoints.json
                    #     for Graph API definitions; @notionhq/notion-mcp-server
                    #     reads ../scripts/notion-openapi.json. The build script
                    #     ships a stripped package.json (no "type":"module") next
                    #     to dist/ so __dirname/../package.json resolves correctly.
                    #     See scripts/build-app.sh `build_mcp_bundle_dir`.
                    #
                    #  2. Single-file bundle: mcp-bundles/<safe>.js
                    #     Used when the SDK is fully self-contained
                    #     (reddit-mcp-buddy).
                    #
                    # Scoped names get flattened ("@softeria/ms-365-mcp-server"
                    # -> "softeria-ms-365-mcp-server") for filesystem safety.
                    safe_bundle = pkg_name.replace("/", "-").replace("@", "")
                    bundle_dir_path = os.path.join(_backend, "mcp-bundles", safe_bundle, "dist", "index.js")
                    bundle_file_path = os.path.join(_backend, "mcp-bundles", f"{safe_bundle}.js")
                    bundle_path = None
                    if os.path.isfile(bundle_dir_path):
                        bundle_path = bundle_dir_path
                    elif os.path.isfile(bundle_file_path):
                        bundle_path = bundle_file_path
                    # Prefer the bundled real-Node binary over Electron-as-Node:
                    # avoids the bouncing "exec" Dock icon on fresh user Macs +
                    # spawns ~10x faster than re-execing the OpenSwarm Electron
                    # binary as Node. Falls back to Electron-as-Node only if
                    # the bundled node payload wasn't shipped (legacy builds).
                    bundled_node = os.environ.get("OPENSWARM_NODE_PATH")
                    if bundle_path and bundled_node and os.path.exists(bundled_node):
                        config["command"] = bundled_node
                        config["args"] = [bundle_path]
                        logger.info(f"Using bundled MCP server for {pkg_name} via bundled node ({bundle_path})")
                    elif bundle_path and electron_path:
                        config["command"] = electron_path
                        config["args"] = [bundle_path]
                        config.setdefault("env", {})["ELECTRON_RUN_AS_NODE"] = "1"
                        logger.info(f"Using bundled MCP server for {pkg_name} ({bundle_path})")
                    else:
                        # Check for pre-installed npm package (works in both dev and packaged modes)
                        safe_dir = pkg_name.replace("/", "-").replace("@", "")
                        npm_dir = os.path.join(_backend, "npm-servers", safe_dir)
                        pkg_json_path = os.path.join(npm_dir, "node_modules", pkg_name, "package.json")
                        if os.path.isfile(pkg_json_path):
                            import json as _json
                            with open(pkg_json_path) as f:
                                pkg_meta = _json.load(f)
                            bin_field = pkg_meta.get("bin", {})
                            entry = list(bin_field.values())[0] if isinstance(bin_field, dict) else bin_field
                            # Same priority as 9Router / MCP-bundle paths: bundled node > system node > Electron-as-Node.
                            node_cmd = (bundled_node if bundled_node and os.path.exists(bundled_node) else None) \
                                or shutil.which("node") \
                                or electron_path
                            if node_cmd:
                                config["command"] = node_cmd
                                config["args"] = [os.path.join(npm_dir, "node_modules", pkg_name, entry)]
                                if node_cmd == electron_path:
                                    config.setdefault("env", {})["ELECTRON_RUN_AS_NODE"] = "1"
                                logger.info(f"Using pre-installed npm MCP server for {pkg_name}")

            if not os.path.isabs(config.get("command", "")):
                resolved = _resolve_command(config["command"])
                if resolved:
                    config["command"] = resolved
                else:
                    logger.warning(f"Command '{config['command']}' not found on PATH or bundled directories")
        env = config.setdefault("env", {})
        env.setdefault("PATH", _augmented_path())
        env.setdefault("PYTHONPATH", "")
        # Point uv/uvx at our bundled Python; avoids macOS CLT popup on fresh Macs
        # and avoids downloading Python at runtime
        _is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
        _is_windows = sys.platform == "win32"
        if _is_packaged:
            _resources = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
            if _is_windows:
                _bundled_python = os.path.join(_resources, "python-env", "python.exe")
            else:
                _bundled_python = os.path.join(_resources, "python-env", "bin", "python3")
            if os.path.exists(_bundled_python):
                env.setdefault("UV_PYTHON", _bundled_python)
        else:
            _backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
            if _is_windows:
                _venv_python = os.path.join(_backend, ".venv", "Scripts", "python.exe")
            else:
                _venv_python = os.path.join(_backend, ".venv", "bin", "python3")
            if os.path.exists(_venv_python):
                env.setdefault("UV_PYTHON", _venv_python)

    return config
