"""WebSocket message handler — dispatches JSON messages to engine/core.

Mirrors the desktop sidecar's command dispatch but routes responses over
WebSocket (broadcast for streaming events, send_to for request-response).
"""
from __future__ import annotations

import asyncio
import json as _json
import logging
import os
from pathlib import Path
from typing import Any

from starlette.websockets import WebSocket

from icarus_server.ws import ConnectionManager

logger = logging.getLogger(__name__)


def _resolve_skill_dirs(server_dir: Path, user_dir: Path) -> dict[str, Path]:
    """Resolve skill directories with source labels.

    Returns {source_label: path}:
    - "built_in": bundled skills (from core)
    - "global": admin-installed (server_dir/skills/)
    - "user": user-created (user_dir/skills/)
    """
    from icarus.config.app_config import settings

    dirs: dict[str, Path] = {}
    built_in = settings.get_built_in_skills_dir()
    if built_in:
        dirs["built_in"] = built_in
    global_skills = server_dir / "skills"
    if global_skills.is_dir():
        dirs["global"] = global_skills
    user_skills = user_dir / "skills"
    if user_skills.is_dir():
        dirs["user"] = user_skills
    return dirs


def _merge_mcp_configs_sync(global_path: Path, user_path: Path) -> dict[str, dict]:
    """Merge global and user MCP configs. Global = read-only for users."""
    servers: dict[str, dict] = {}
    for path, source in [(global_path, "global"), (user_path, "private")]:
        if path.exists():
            try:
                data = _json.loads(path.read_text())
                for name, config in data.get("mcpServers", {}).items():
                    servers[name] = {**config, "_source": source}
            except (_json.JSONDecodeError, OSError):
                pass
    return servers


def _parse_cursor(cmd: dict) -> tuple[str, str] | None:
    raw = cmd.get("cursor")
    if not raw:
        return None
    return (raw["updated_at"], raw["thread_id"])


class MessageHandler:
    """Dispatch inbound WS messages to IcarusEngine / core functions."""

    def __init__(self, pool: Any, manager: ConnectionManager) -> None:
        self._pool = pool
        self._mgr = manager

    # ── Engine resolution ─────────────────────────────────────

    def _engine_for(self, ws: WebSocket) -> tuple[Any, str]:
        """Resolve per-user engine and user_id. Does NOT bump refcount."""
        user_id = self._mgr.get_user_id(ws)
        engine = self._pool.get(user_id)
        if engine is None:
            raise RuntimeError(f"No engine for user {user_id}")
        return engine, user_id

    # ── Public entry point ───────────────────────────────────

    # Commands that read-only users can execute
    _READ_COMMANDS = frozenset({
        "list_threads", "get_history", "search_threads",
        "list_models", "list_skills", "get_skill_content",
        "list_plugins", "list_connectors",
    })

    # Commands restricted to admin role
    _ADMIN_COMMANDS = frozenset({
        "set_credentials", "get_credentials",
        "set_workspace", "set_model", "set_default_model",
        "add_connector", "update_connector", "remove_connector",
        "toggle_connector", "retry_connector",
        "add_plugin", "remove_plugin",
    })

    async def handle(self, ws: WebSocket, msg: dict[str, Any]) -> None:
        cmd = msg.get("cmd")
        request_id = msg.get("request_id")
        user_id = self._mgr.get_user_id(ws)
        role = self._mgr.get_role(ws)
        logger.debug("Handling %s for user %s (role=%s)", cmd, user_id, role)

        # Role-based authorization
        if role == "pending":
            await self._reply(ws, request_id, {"type": "error", "message": "Account pending approval"})
            return
        if role == "read-only" and cmd not in self._READ_COMMANDS:
            await self._reply(ws, request_id, {"type": "error", "message": "Read-only access"})
            return
        if cmd in self._ADMIN_COMMANDS and role != "admin":
            await self._reply(ws, request_id, {"type": "error", "message": "Admin access required"})
            return

        if cmd == "approve":
            engine, _ = self._engine_for(ws)
            self._handle_hitl_approve(msg, engine=engine); return
        if cmd == "reject":
            engine, _ = self._engine_for(ws)
            self._handle_hitl_reject(msg, engine=engine); return

        handler = self._HANDLERS.get(cmd)
        if not handler:
            await self._reply(ws, request_id, {"type": "error", "message": f"Unknown command: {cmd}"})
            return
        try:
            engine, uid = self._engine_for(ws)
            await getattr(self, handler)(ws, msg, request_id, engine=engine, user_id=uid)
        except Exception:
            logger.exception("Error handling command: %s", cmd)
            await self._reply(ws, request_id, {"type": "error", "message": f"Internal error handling {cmd}"})

    _HANDLERS: dict[str, str] = {
        "send": "_handle_send", "cancel": "_handle_cancel",
        "list_threads": "_handle_list_threads", "get_history": "_handle_get_history",
        "delete_thread": "_handle_delete_thread", "rename_thread": "_handle_rename_thread",
        "search_threads": "_handle_search_threads", "set_workspace": "_handle_set_workspace",
        "list_models": "_handle_list_models", "set_model": "_handle_set_model",
        "set_default_model": "_handle_set_default_model",
        "get_credentials": "_handle_get_credentials", "set_credentials": "_handle_set_credentials",
        "list_skills": "_handle_list_skills", "create_skill": "_handle_create_skill",
        "delete_skill": "_handle_delete_skill", "get_skill_content": "_handle_get_skill_content",
        "add_plugin": "_handle_add_plugin", "list_plugins": "_handle_list_plugins",
        "remove_plugin": "_handle_remove_plugin", "fetch_plugin_skills": "_handle_fetch_plugin_skills",
        "list_connectors": "_handle_list_connectors", "add_connector": "_handle_add_connector",
        "update_connector": "_handle_update_connector", "remove_connector": "_handle_remove_connector",
        "toggle_connector": "_handle_toggle_connector", "retry_connector": "_handle_retry_connector",
    }

    # ── Response helpers ─────────────────────────────────────

    async def _reply(self, ws: WebSocket, request_id: str | None, data: dict[str, Any]) -> None:
        if request_id:
            data["request_id"] = request_id
        await self._mgr.send_to(ws, data)

    async def _broadcast(self, data: dict[str, Any], *, user_id: str) -> None:
        """Broadcast to a specific user's connections only."""
        await self._mgr.broadcast_to_user(user_id, data)

    # ── Emit bridge (sync engine callback -> async WS) ───────

    def _make_emit(self, engine: Any, thread_id: str, *, user_id: str, index_content: str | None = None):
        """Sync-safe emit backed by asyncio.Queue. Returns (queue, emit, relay_task).

        Events are scoped to the specified user's connections only.
        When *index_content* is provided, the relay fires search indexing
        on stream completion.
        """
        queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()

        def emit(data: dict[str, Any]) -> None:
            queue.put_nowait(data)
            evt = data.get("type")
            if evt in ("done", "cancelled") or (evt == "error" and data.get("thread_id")):
                queue.put_nowait(None)

        async def relay() -> None:
            while True:
                item = await queue.get()
                if item is None:
                    break
                await self._broadcast(item, user_id=user_id)
                if item.get("type") in ("done", "cancelled"):
                    self._mgr.unregister_thread(thread_id)
                if index_content is not None and item.get("type") == "done":
                    from icarus.session.search import safe_index_thread
                    st = engine.stream_text.get(thread_id)
                    ai_text = st.ai_text if st else ""
                    asyncio.create_task(safe_index_thread(
                        thread_id, index_content, ai_text, db_path=engine.db_path,
                    ))

        return queue, emit, asyncio.create_task(relay(), name=f"ws-relay-{thread_id}")

    # ── HITL ─────────────────────────────────────────────────

    def _resolve_thread_id(self, msg: dict[str, Any], *, engine: Any) -> str | None:
        tid = msg.get("thread_id")
        if not tid and len(engine.hitl_futures) == 1:
            tid = next(iter(engine.hitl_futures))
        return tid

    def _handle_hitl_approve(self, msg: dict[str, Any], *, engine: Any) -> None:
        tid = self._resolve_thread_id(msg, engine=engine)
        if tid:
            engine.approve(tid, auto_approve_all=msg.get("auto_approve_all", False))

    def _handle_hitl_reject(self, msg: dict[str, Any], *, engine: Any) -> None:
        tid = self._resolve_thread_id(msg, engine=engine)
        if tid:
            engine.reject(tid)

    # ── send / cancel ────────────────────────────────────────

    async def _handle_send(self, ws, msg, request_id, *, engine, user_id) -> None:
        thread_id = msg.get("thread_id")
        content = msg.get("content", "")
        if not thread_id or not content:
            await self._reply(ws, request_id, {"type": "error", "message": "send requires thread_id and content"})
            return
        logger.debug("send: user=%s thread=%s", user_id, thread_id)
        if user_id and thread_id:
            self._mgr.register_thread(thread_id, user_id)
        _, emit, task = self._make_emit(engine, thread_id, user_id=user_id, index_content=content)
        ok = False
        try:
            await engine.send(thread_id, content, emit=emit)
            ok = True
        except RuntimeError:
            await self._broadcast({
                "type": "error", "thread_id": thread_id,
                "message": "Agent is not ready \u2014 no model is configured. Please set your API keys in Settings.",
            }, user_id=user_id)
        finally:
            if ok:
                await task
            else:
                task.cancel()
                await asyncio.gather(task, return_exceptions=True)

    async def _handle_cancel(self, ws, msg, request_id, *, engine, user_id) -> None:
        thread_id = msg.get("thread_id")
        if thread_id:
            had_stream = thread_id in engine.streams
            await engine.cancel(thread_id)
            if not had_stream:
                await self._broadcast({"type": "cancelled", "thread_id": thread_id}, user_id=user_id)
        else:
            await self._reply(ws, request_id, {"type": "cancelled"})

    # ── Thread CRUD ──────────────────────────────────────────

    async def _handle_list_threads(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.session.threads import _DEFAULT_THREAD_LIMIT, list_threads
        limit = max(1, msg.get("limit", _DEFAULT_THREAD_LIMIT))
        threads = await list_threads(
            include_message_count=True, limit=limit, cursor=_parse_cursor(msg),
            db_path=engine.db_path,
        )
        for t in threads:
            t["status"] = engine.state_manager.get_state(t["thread_id"]).value
        engine.known_threads.update(t["thread_id"] for t in threads)
        next_cursor = (
            {"updated_at": threads[-1]["updated_at"], "thread_id": threads[-1]["thread_id"]}
            if threads and len(threads) == limit else None
        )
        await self._reply(ws, request_id, {"type": "threads_list", "threads": [dict(t) for t in threads], "next_cursor": next_cursor})

    async def _handle_get_history(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.session.stream import build_stream_config
        from icarus.session.serialize import serialize_message
        thread_id = msg.get("thread_id", "")
        if not thread_id:
            await self._reply(ws, request_id, {"type": "error", "message": "get_history requires thread_id"})
            return

        config = build_stream_config(thread_id, "default")
        if engine.agent is not None:
            state = await engine.agent.aget_state(config)
            messages = (state.values or {}).get("messages", []) if state else []
        elif engine.checkpointer is not None:
            tup = await engine.checkpointer.aget_tuple(config)
            messages = (tup.checkpoint.get("channel_values", {}).get("messages", [])
                        if tup and tup.checkpoint else [])
        else:
            messages = []

        serialized = [serialize_message(m) for m in messages]

        # Append in-progress stream text for mid-stream history replay
        st = engine.stream_text.get(thread_id)
        if st is not None:
            has_human = any(m.get("role") == "human" and m.get("content") == st.human_content for m in serialized)
            if not has_human:
                serialized.append({"role": "human", "content": st.human_content, "id": f"human-{thread_id[:8]}"})
            if st.ai_message_id and st.ai_text:
                serialized.append({"role": "ai", "content": st.ai_text, "id": st.ai_message_id})

        await self._reply(ws, request_id, {"type": "thread_history", "messages": serialized})

        if thread_id in engine.hitl_requests:
            await self._broadcast({
                "type": "hitl_request", "action_requests": engine.hitl_requests[thread_id],
                "assistant_id": "default", "thread_id": thread_id,
            }, user_id=user_id)

    async def _handle_delete_thread(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.session.threads import delete_thread
        thread_id = msg.get("thread_id", "")
        if not thread_id:
            await self._reply(ws, request_id, {"type": "error", "message": "delete_thread requires thread_id"})
            return
        await delete_thread(thread_id, db_path=engine.db_path)
        try:
            from icarus.session.search import delete_search_index
            await delete_search_index(thread_id, db_path=engine.db_path)
        except Exception:
            logger.debug("Search index cleanup failed for %s", thread_id, exc_info=True)
        engine.known_threads.discard(thread_id)
        engine.titled_threads.discard(thread_id)
        engine.state_manager.clear(thread_id)
        self._mgr.unregister_thread(thread_id)
        await self._reply(ws, request_id, {"type": "thread_deleted", "thread_id": thread_id})

    async def _handle_rename_thread(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.session.threads import rename_thread
        thread_id, title = msg.get("thread_id", ""), msg.get("title", "").strip()
        if not thread_id or not title:
            await self._reply(ws, request_id, {"type": "error", "message": "rename_thread requires thread_id and title"})
            return
        await rename_thread(thread_id, title, db_path=engine.db_path)
        await self._reply(ws, request_id, {"type": "thread_renamed", "thread_id": thread_id, "title": title})

    async def _handle_search_threads(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.session.search import search_threads
        query = msg.get("query", "")
        result = await search_threads(
            query=query, limit=max(1, msg.get("limit", 20)), cursor=_parse_cursor(msg),
            db_path=engine.db_path,
        )
        for r in result["results"]:
            r["status"] = engine.state_manager.get_state(r["thread_id"]).value
        next_cursor = (
            {"updated_at": result["next_cursor"][0], "thread_id": result["next_cursor"][1]}
            if result["next_cursor"] else None
        )
        await self._reply(ws, request_id, {"type": "search_results", "results": result["results"], "query": query, "next_cursor": next_cursor})

    # ── Workspace / Model / Credentials (engine) ─────────────

    async def _handle_set_workspace(self, ws, msg, request_id, *, engine, user_id) -> None:
        path = msg.get("path", "").strip()
        if not path:
            await self._reply(ws, request_id, {"type": "error", "message": "set_workspace requires path"})
            return
        try:
            await self._reply(ws, request_id, await engine.set_workspace(path))
        except Exception as exc:
            logger.exception("Failed: set_workspace")
            await self._reply(ws, request_id, {"type": "error", "message": str(exc)})

    async def _handle_list_models(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.session.model import list_models
        info = list_models()
        current = engine.current_model_spec() or info.current
        await self._reply(ws, request_id, {"type": "models_list", "models": info.models, "current": current, "default": info.default})

    async def _handle_set_model(self, ws, msg, request_id, *, engine, user_id) -> None:
        model = msg.get("model", "").strip()
        if not model:
            await self._reply(ws, request_id, {"type": "error", "message": "set_model requires model"})
            return
        try:
            await self._reply(ws, request_id, await engine.set_model(model))
        except Exception as exc:
            logger.exception("Failed: set_model")
            await self._reply(ws, request_id, {"type": "error", "message": str(exc)})

    async def _handle_set_default_model(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.session.model import set_default
        model = msg.get("model", "").strip()
        if not model:
            await self._reply(ws, request_id, {"type": "error", "message": "set_default_model requires model"})
            return
        result = set_default(model)
        if not result.ok:
            await self._reply(ws, request_id, {"type": "error", "message": result.error})
            return
        if engine.current_model_spec() != result.display_name:
            engine.pending_model = result.display_name
        await self._reply(ws, request_id, {"type": "default_model_set", "model": result.display_name})

    async def _handle_get_credentials(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.config.model_config import get_provider_registry
        await self._reply(ws, request_id, {"type": "credentials_info", "providers": get_provider_registry()})

    async def _handle_set_credentials(self, ws, msg, request_id, *, engine, user_id) -> None:
        try:
            result = await engine.set_credentials(
                msg.get("credentials", {}), msg.get("provider_models"), msg.get("provider_endpoints"),
            )
            await self._reply(ws, request_id, result)
        except Exception as exc:
            logger.exception("Failed: set_credentials")
            await self._reply(ws, request_id, {"type": "error", "message": str(exc)})

    # ── Skills (core/) ───────────────────────────────────────

    async def _handle_list_skills(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.skills.load import list_skills
        skill_dirs = _resolve_skill_dirs(self._pool.server_dir, engine.ctx.home_dir)
        skills = list_skills(
            built_in_skills_dir=skill_dirs.get("built_in"),
            user_skills_dir=skill_dirs.get("user"),
            project_skills_dir=skill_dirs.get("global"),  # admin-installed = "project" slot
            plugins_base_dir=self._pool.server_dir / "plugins",
        )
        await self._reply(ws, request_id, {"type": "skills_list", "skills": [
            {"name": s["name"], "description": s.get("description", ""), "source": s.get("source", "unknown"), "path": s.get("path", "")}
            for s in skills
        ]})

    async def _handle_create_skill(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.skills.validation import validate_skill_name, validate_skill_path
        name = msg.get("name", "").strip()
        if not name:
            await self._reply(ws, request_id, {"type": "error", "message": "create_skill requires name"})
            return
        ok, err = validate_skill_name(name)
        if not ok:
            await self._reply(ws, request_id, {"type": "error", "message": f"Invalid skill name: {err}"})
            return
        description = msg.get("description", "").strip()
        instructions = msg.get("instructions", "").strip()
        skills_dir = engine.ctx.home_dir / "skills"
        skills_dir.mkdir(parents=True, exist_ok=True)
        skill_dir = skills_dir / name
        ok, err = validate_skill_path(skill_dir, skills_dir)
        if not ok:
            await self._reply(ws, request_id, {"type": "error", "message": err})
            return
        try:
            skill_dir.mkdir(parents=True, exist_ok=False)
        except FileExistsError:
            await self._reply(ws, request_id, {"type": "error", "message": f"Skill '{name}' already exists"})
            return
        title = name.title().replace("-", " ")
        desc_line = description or f"TODO: Describe what {name} does and when to use it."
        body = instructions or f"# {title}\n\n## Overview\n\n[TODO: Instructions for this skill]\n"
        (skill_dir / "SKILL.md").write_text(f"---\nname: {name}\ndescription: \"{desc_line}\"\n---\n\n{body}\n")
        await self._reply(ws, request_id, {"type": "skill_created", "name": name, "path": str(skill_dir)})

    async def _handle_delete_skill(self, ws, msg, request_id, *, engine, user_id) -> None:
        import shutil
        from icarus.skills.validation import validate_skill_name
        name = msg.get("name", "").strip()
        if not name:
            await self._reply(ws, request_id, {"type": "error", "message": "delete_skill requires name"})
            return
        ok, err = validate_skill_name(name)
        if not ok:
            await self._reply(ws, request_id, {"type": "error", "message": f"Invalid skill name: {err}"})
            return
        # Delete from per-user skills dir only (not global)
        user_skills = engine.ctx.home_dir / "skills" / name
        if not user_skills.exists():
            await self._reply(ws, request_id, {"type": "error", "message": f"Skill '{name}' not found"})
            return
        await asyncio.to_thread(shutil.rmtree, user_skills)
        await self._reply(ws, request_id, {"type": "skill_deleted", "name": name})

    async def _handle_get_skill_content(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.skills.validation import validate_plugin_name, validate_skill_name
        name = msg.get("name", "").strip()
        if not name:
            await self._reply(ws, request_id, {"type": "error", "message": "get_skill_content requires name"})
            return
        ok, err = validate_skill_name(name)
        if not ok:
            await self._reply(ws, request_id, {"type": "error", "message": f"Invalid skill name: {err}"})
            return
        skill_dirs = _resolve_skill_dirs(self._pool.server_dir, engine.ctx.home_dir)
        source = msg.get("source", "user")
        if source == "built-in":
            bi = skill_dirs.get("built_in")
            if not bi:
                await self._reply(ws, request_id, {"type": "error", "message": "Built-in skills directory not found"})
                return
            skill_dir = bi / name
        elif source == "project" or source == "global":
            gd = skill_dirs.get("global")
            if not gd:
                await self._reply(ws, request_id, {"type": "error", "message": "No global skills directory found"})
                return
            skill_dir = gd / name
        elif source.startswith("plugin:"):
            plugin_name = source[len("plugin:"):]
            ok, err = validate_plugin_name(plugin_name)
            if not ok:
                await self._reply(ws, request_id, {"type": "error", "message": f"Invalid plugin name: {err}"})
                return
            skill_dir = self._pool.server_dir / "plugins" / plugin_name / "skills" / name
        else:
            skill_dir = engine.ctx.home_dir / "skills" / name
        skill_md = skill_dir / "SKILL.md"
        if not skill_md.exists():
            await self._reply(ws, request_id, {"type": "error", "message": f"SKILL.md not found for '{name}'"})
            return
        content = skill_md.read_text(encoding="utf-8")
        await self._reply(ws, request_id, {"type": "skill_content", "name": name, "content": content, "path": str(skill_dir)})

    # ── Plugins (core/) ──────────────────────────────────────

    async def _handle_add_plugin(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.skills.registry_config import RegistryEntry, SkillsConfig
        from icarus.skills.validation import validate_plugin_name
        url = msg.get("url", "").strip()
        if not url:
            await self._reply(ws, request_id, {"type": "error", "message": "add_plugin requires url"})
            return
        token = msg.get("token", "").strip()
        if "/" in url and not url.startswith("github:"):
            url = f"github:{url}"
        name = url.split("/")[-1] if "/" in url else url
        ok, err = validate_plugin_name(name)
        if not ok:
            await self._reply(ws, request_id, {"type": "error", "message": f"Invalid plugin name: {err}"})
            return
        token_env = ""
        try:
            if token:
                token_env = f"REGISTRY_TOKEN_{name.upper().replace('-', '_')}"
                os.environ[token_env] = token
            entry = RegistryEntry(name=name, url=url, token_env=token_env)

            _plugins_base = self._pool.server_dir / "plugins"

            def _sync_install() -> list[str]:
                from icarus.skills.installer import install_skill
                from icarus.skills.registry import fetch_index
                index = fetch_index(entry)
                if not index:
                    raise ValueError(f"No skills found in '{name}' — check the URL and token")
                installed = []
                for skill in index:
                    try:
                        result = install_skill(skill.name, registry=entry, known_version=skill.version, plugins_base=_plugins_base)
                        installed.append(result.name)
                    except Exception:
                        logger.warning("Failed to install skill %s from %s", skill.name, name)
                return installed

            installed = await asyncio.to_thread(_sync_install)
            if token and token_env:
                from icarus.config.model_config import save_credentials
                await asyncio.to_thread(save_credentials, {token_env: token})
            cfg_path = self._pool.server_dir / "config.toml"

            def _save_registry():
                config = SkillsConfig.load(cfg_path)
                config.add_registry(entry)
                config.save(cfg_path)

            await asyncio.to_thread(_save_registry)
            await self._reply(ws, request_id, {"type": "plugin_added", "name": name, "url": url, "installed_skills": installed})
        except Exception as exc:
            if token_env:
                os.environ.pop(token_env, None)
            await self._reply(ws, request_id, {"type": "error", "message": str(exc)})

    async def _handle_list_plugins(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.skills.registry_config import SkillsConfig
        config = await asyncio.to_thread(SkillsConfig.load, self._pool.server_dir / "config.toml")
        await self._reply(ws, request_id, {"type": "plugins_list", "plugins": [
            {"name": r.name, "url": r.url, "has_token": bool(r.token_env)}
            for r in config.registries if r.name != "icarus"
        ]})

    async def _handle_remove_plugin(self, ws, msg, request_id, *, engine, user_id) -> None:
        import shutil
        from icarus.skills.registry_config import SkillsConfig
        from icarus.skills.validation import validate_plugin_name
        name = msg.get("name", "").strip()
        if not name:
            await self._reply(ws, request_id, {"type": "error", "message": "remove_plugin requires name"})
            return
        ok, err = validate_plugin_name(name)
        if not ok:
            await self._reply(ws, request_id, {"type": "error", "message": f"Invalid plugin name: {err}"})
            return
        plugin_dir = self._pool.server_dir / "plugins" / name
        if plugin_dir.exists():
            await asyncio.to_thread(shutil.rmtree, plugin_dir)
        cfg_path = self._pool.server_dir / "config.toml"

        def _remove_registry():
            config = SkillsConfig.load(cfg_path)
            config.remove_registry(name)
            config.save(cfg_path)

        await asyncio.to_thread(_remove_registry)
        await self._reply(ws, request_id, {"type": "plugin_removed", "name": name})

    async def _handle_fetch_plugin_skills(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.skills.registry import fetch_index
        from icarus.skills.registry_config import SkillsConfig
        from icarus.skills.validation import validate_plugin_name
        plugin = msg.get("plugin", "").strip()
        if not plugin:
            await self._reply(ws, request_id, {"type": "error", "message": "fetch_plugin_skills requires plugin"})
            return
        ok, err = validate_plugin_name(plugin)
        if not ok:
            await self._reply(ws, request_id, {"type": "error", "message": f"Invalid plugin name: {err}"})
            return
        config = await asyncio.to_thread(SkillsConfig.load, self._pool.server_dir / "config.toml")
        entry = next((r for r in config.registries if r.name == plugin), None)
        if not entry:
            await self._reply(ws, request_id, {"type": "error", "message": f"Plugin '{plugin}' not found"})
            return

        plugins_base = self._pool.server_dir / "plugins"

        def _sync_fetch():
            idx = fetch_index(entry)
            pdir = plugins_base / plugin / "skills"
            installed = {d.name for d in pdir.iterdir() if d.is_dir()} if pdir.exists() else set()
            return idx, installed

        index, installed = await asyncio.to_thread(_sync_fetch)
        await self._reply(ws, request_id, {"type": "plugin_skills_list", "plugin": plugin, "skills": [
            {"name": s.name, "description": s.description, "version": s.version, "tags": s.tags, "installed": s.name in installed}
            for s in index
        ]})

    # ── Connectors (MCP — core/) ─────────────────────────────

    def _build_connector_list(self, engine: Any, config: dict[str, Any] | None = None) -> list[dict[str, Any]]:
        from icarus.mcp.config import list_servers, read_mcp_config
        if config is None:
            config = read_mcp_config(engine.mcp_config_path)
        mgr = engine.mcp_manager
        status = mgr.server_status if mgr else {}
        tools = mgr.server_tools if mgr else {}
        errors = mgr.server_errors if mgr else {}
        result = []
        for entry in list_servers(config):
            info: dict[str, Any] = {
                "name": entry.name, "type": entry.transport_type, "enabled": entry.enabled,
                "status": status.get(entry.name, "disabled" if not entry.enabled else "unknown"),
                "error": errors.get(entry.name, ""), "tools": tools.get(entry.name, []),
            }
            for key in ("command", "args", "env", "url", "headers"):
                if key in entry.config:
                    info[key] = entry.config[key]
            result.append(info)
        return result

    async def _mutate_and_reload(self, ws, request_id, response_type, mutate_fn, name, *args, engine):
        from icarus.mcp.config import read_mcp_config, write_mcp_config
        config_path = engine.mcp_config_path
        config = await asyncio.to_thread(read_mcp_config, config_path)
        mutate_fn(config, name, *args)
        await asyncio.to_thread(write_mcp_config, config_path, config)
        try:
            await engine.reload_mcp()
        except Exception:
            logger.exception("MCP reload failed after mutating connector %s", name)
        await self._reply(ws, request_id, {"type": response_type, "connectors": self._build_connector_list(engine, config)})

    async def _handle_list_connectors(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.mcp.config import read_mcp_config
        # Merge global (server-level) + private (per-user) MCP configs
        global_path = self._pool.server_dir / "mcp.json"
        user_path = engine.mcp_config_path  # per-user
        merged_servers = await asyncio.to_thread(_merge_mcp_configs_sync, global_path, user_path)
        # Build the standard connector list from the user's own config (for status tracking)
        user_config = await asyncio.to_thread(read_mcp_config, user_path)
        connectors = self._build_connector_list(engine, user_config)
        # Add global connectors that aren't already in the user's config
        existing_names = {c["name"] for c in connectors}
        for name, cfg in merged_servers.items():
            if name not in existing_names and cfg.get("_source") == "global":
                source = cfg.pop("_source", "global")
                connectors.append({
                    "name": name, "type": "http" if "url" in cfg else "stdio",
                    "enabled": True, "status": "unknown", "error": "",
                    "tools": [], "_source": source,
                    **{k: cfg[k] for k in ("command", "args", "env", "url", "headers") if k in cfg},
                })
        await self._reply(ws, request_id, {"type": "connectors_list", "connectors": connectors})

    async def _handle_add_connector(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.mcp import MCPManager
        from icarus.mcp.config import add_server
        name = msg.get("name", "").strip()
        config_data = msg.get("config", {})
        if not name:
            await self._reply(ws, request_id, {"type": "error", "message": "add_connector requires name"})
            return
        if not config_data:
            await self._reply(ws, request_id, {"type": "error", "message": "add_connector requires config"})
            return
        target = config_data.get("url") or config_data.get("command", "server")
        try:
            await MCPManager.probe(config_data)
        except Exception:
            await self._reply(ws, request_id, {"type": "error", "message": f"Could not reach {target} \u2014 make sure the server is running"})
            return
        await self._mutate_and_reload(ws, request_id, "connector_added", add_server, name, config_data, engine=engine)

    async def _handle_update_connector(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.mcp.config import update_server
        name = msg.get("name", "").strip()
        config_data = msg.get("config", {})
        if not name:
            await self._reply(ws, request_id, {"type": "error", "message": "update_connector requires name"})
            return
        if not config_data:
            await self._reply(ws, request_id, {"type": "error", "message": "update_connector requires config"})
            return
        await self._mutate_and_reload(ws, request_id, "connector_updated", update_server, name, config_data, engine=engine)

    async def _handle_remove_connector(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.mcp.config import remove_server
        name = msg.get("name", "").strip()
        if not name:
            await self._reply(ws, request_id, {"type": "error", "message": "remove_connector requires name"})
            return
        await self._mutate_and_reload(ws, request_id, "connector_removed", remove_server, name, engine=engine)

    async def _handle_toggle_connector(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.mcp.config import toggle_server
        name = msg.get("name", "").strip()
        if not name:
            await self._reply(ws, request_id, {"type": "error", "message": "toggle_connector requires name"})
            return
        await self._mutate_and_reload(ws, request_id, "connector_toggled", toggle_server, name, engine=engine)

    async def _handle_retry_connector(self, ws, msg, request_id, *, engine, user_id) -> None:
        from icarus.mcp.config import read_mcp_config
        name = msg.get("name", "").strip()
        if not name:
            await self._reply(ws, request_id, {"type": "error", "message": "retry_connector requires name"})
            return
        config_path = engine.mcp_config_path
        config = await asyncio.to_thread(read_mcp_config, config_path)
        cfg = config.get("mcpServers", {}).get(name) or config.get("_disabled", {}).get(name)
        if not cfg:
            await self._reply(ws, request_id, {"type": "error", "message": f"Connector '{name}' not found"})
            return
        from icarus.mcp import MCPManager
        target = cfg.get("url") or cfg.get("command", "server")
        try:
            await MCPManager.probe(cfg)
        except Exception:
            await self._reply(ws, request_id, {"type": "error", "message": f"Still cannot reach {target}"})
            return
        if name in config.get("_disabled", {}):
            from icarus.mcp.config import toggle_server, write_mcp_config
            toggle_server(config, name)
            await asyncio.to_thread(write_mcp_config, config_path, config)
        await engine.reload_mcp()
        await self._reply(ws, request_id, {"type": "connector_retried", "connectors": self._build_connector_list(engine, config)})
