"""Sidecar entry point — bridges Tauri desktop app to the agent engine.

Reads JSON commands from stdin, delegates lifecycle and streaming to
``IcarusEngine``, handles CRUD operations by calling ``core/`` directly,
and writes JSON lines to stdout.

Only runs when stdin is a pipe (spawned by Tauri).  If run from a
terminal, prints a message and exits — use the ``icarus`` CLI instead.

Stdout is **exclusively** for JSON events.  All logging goes to stderr
(or ``~/.icarus/sidecar.log`` when configured).
"""

from __future__ import annotations

import asyncio
import functools
import json
import logging
import os
import shutil
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any

from icarus.sidecar.protocol import (
    parse_command,
    serialize_message,
    write_response,
)

logger = logging.getLogger(__name__)


# ------------------------------------------------------------------
# Handler decorator (boilerplate elimination)
# ------------------------------------------------------------------


class HandlerError(Exception):
    """User-facing error raised inside handler functions.

    The ``sidecar_handler`` decorator catches this and writes an error
    response with the message.  Use for validation failures and
    expected error conditions.
    """


Validator = Callable[[str], tuple[bool, str]]


def _skill_name_validator() -> Validator:
    from icarus.skills.validation import validate_skill_name
    return validate_skill_name


def _plugin_name_validator() -> Validator:
    from icarus.skills.validation import validate_plugin_name
    return validate_plugin_name


# Pre-built (param_key, lazy_validator, label) tuples for common cases.
VALIDATE_SKILL_NAME = lambda p: (p, _skill_name_validator, "skill name")  # noqa: E731
VALIDATE_PLUGIN_NAME = lambda p: (p, _plugin_name_validator, "plugin name")  # noqa: E731


def sidecar_handler(
    response_type: str | None = None,
    required: list[str] | None = None,
    validate: list[tuple[str, Callable[[], Validator], str]] | None = None,
):
    """Decorator that eliminates common handler boilerplate.

    Handles: parameter extraction, name validation, try/except wrapping,
    and response serialization.

    Args:
        response_type: If set and the handler returns a dict, inject this
            as ``type`` and write the response.  If the handler returns
            None, no auto-response is written (handler wrote its own).
        required: List of cmd keys to extract, strip, and validate as
            non-empty.  Extracted values are passed as keyword arguments.
        validate: List of ``(param, validator_factory, label)`` tuples.
            Each ``param`` must be in ``required``.  The factory is called
            once to get a ``(str) -> (bool, str)`` validator.  On failure
            an error response is written.  Use ``VALIDATE_SKILL_NAME``
            or ``VALIDATE_PLUGIN_NAME`` helpers for common cases.
    """

    def decorator(fn):
        is_async = asyncio.iscoroutinefunction(fn)

        @functools.wraps(fn)
        async def wrapper(self, cmd: dict[str, Any]) -> None:
            # 1. Extract + validate required params
            kwargs: dict[str, Any] = {}
            if required:
                for param in required:
                    val = cmd.get(param, "")
                    if isinstance(val, str):
                        val = val.strip()
                    if not val:
                        cmd_name = cmd.get("cmd", fn.__name__.replace("_handle_", ""))
                        write_response({
                            "type": "error",
                            "message": f"{cmd_name} requires {param}",
                        })
                        return
                    kwargs[param] = val

            # 2. Run validators
            if validate:
                for param, validator_factory, label in validate:
                    if param not in kwargs:
                        continue
                    is_valid, error_msg = validator_factory()(kwargs[param])
                    if not is_valid:
                        write_response({
                            "type": "error",
                            "message": f"Invalid {label}: {error_msg}",
                        })
                        return

            # 3. Call handler with error wrapping
            try:
                result = (await fn(self, cmd, **kwargs)) if is_async else fn(self, cmd, **kwargs)

                # 4. Auto-wrap response if handler returns a dict
                if result is not None and response_type:
                    result["type"] = response_type
                    write_response(result)
            except HandlerError as exc:
                write_response({"type": "error", "message": str(exc)})
            except Exception as exc:
                prefix = fn.__name__.replace("_handle_", "")
                logger.exception("Failed: %s", prefix)
                write_response({"type": "error", "message": str(exc)})

        return wrapper

    return decorator


# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------


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


class Sidecar:
    """Long-lived IPC process bridging stdin/stdout JSON to the agent engine.

    Lifecycle:
        1. ``boot()`` — create engine, start services, signal ready
        2. ``run()``  — async stdin reader loop, dispatch commands
        3. ``shutdown()`` — delegate to engine

    The sidecar delegates lifecycle, streaming, HITL, and state-mutating
    operations to ``IcarusEngine``.  Read-only CRUD handlers (threads,
    skills, plugins, connectors) call ``core/`` functions directly.
    """

    def __init__(self) -> None:
        from icarus.engine import IcarusEngine

        self._engine = IcarusEngine()

        # Subscribe to thread state changes -> write stdout events
        self._engine.state_manager.on_change(
            lambda tid, _old, new: self._emit_state_changed(tid, new)
        )

    # ------------------------------------------------------------------
    # Engine accessors (for test compatibility)
    # ------------------------------------------------------------------

    @property
    def _agent(self) -> Any:
        return self._engine._agent

    @_agent.setter
    def _agent(self, value: Any) -> None:
        self._engine._agent = value

    @property
    def _state_manager(self):
        return self._engine._state_manager

    @property
    def _hitl_futures(self):
        return self._engine._hitl_futures

    @property
    def _streams(self):
        return self._engine._streams

    @property
    def _known_threads(self):
        return self._engine._known_threads

    @_known_threads.setter
    def _known_threads(self, value):
        self._engine._known_threads = value

    @property
    def _pending_model(self):
        return self._engine._pending_model

    @_pending_model.setter
    def _pending_model(self, value):
        self._engine._pending_model = value

    @property
    def _hitl_requests(self):
        return self._engine._hitl_requests

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    async def boot(self) -> None:
        """Initialize the engine and signal ready to the frontend."""
        await self._engine.boot()
        write_response({"type": "ready"})

    async def shutdown(self) -> None:
        """Delegate shutdown to the engine and clean up browser temp dir."""
        from icarus.tools.browser import cleanup_browser_workdir
        cleanup_browser_workdir()
        await self._engine.shutdown()

    # ------------------------------------------------------------------
    # MCP callbacks — override engine defaults to also write stdout
    # ------------------------------------------------------------------

    def _on_mcp_server_failure(self, server_name: str, error_msg: str) -> None:
        """MCP tool failure — log + write connector_warning to stdout."""
        self._orig_on_failure(server_name, error_msg)
        write_response({
            "type": "connector_warning",
            "name": server_name,
            "message": f"Connector '{server_name}' — tools failing: {error_msg}",
        })

    def _on_mcp_server_recovery(self, server_name: str) -> None:
        """MCP tool recovery — log + write connector_recovered to stdout."""
        self._orig_on_recovery(server_name)
        write_response({
            "type": "connector_recovered",
            "name": server_name,
            "message": f"Connector '{server_name}' recovered",
        })

    def _on_mcp_server_reconnect(self, server_name: str) -> None:
        """MCP server reconnect — log + write connector_warning + reload."""
        self._orig_on_reconnect(server_name)
        write_response({
            "type": "connector_warning",
            "name": server_name,
            "message": f"Connector '{server_name}' reconnecting...",
        })
        asyncio.ensure_future(self._engine.reload_mcp())

    async def _on_mcp_boot_failures(self, failed: list[str]) -> None:
        """Auto-disable failed connectors and warn the UI."""
        await self._orig_on_boot_failures(failed)
        await self._auto_disable_connectors(failed)

    async def _auto_disable_connectors(self, failed: list[str]) -> None:
        """Auto-disable failed connectors and warn the UI."""
        from icarus.mcp.config import read_mcp_config, toggle_server, write_mcp_config

        config_path = self._engine._mcp_config_path()
        config = await asyncio.to_thread(read_mcp_config, config_path)
        toggled = False
        for name in failed:
            if name in config.get("mcpServers", {}):
                toggle_server(config, name)
                toggled = True
                error = (
                    self._engine.mcp_manager.server_errors.get(name, "not reachable")
                    if self._engine.mcp_manager
                    else "not reachable"
                )
                write_response({
                    "type": "connector_warning",
                    "name": name,
                    "message": f"Connector '{name}' disabled — {error}",
                })
        if toggled:
            await asyncio.to_thread(write_mcp_config, config_path, config)

    def _install_mcp_callbacks(self) -> None:
        """Replace engine MCP callbacks with sidecar-aware versions.

        Called before boot() so the engine uses our callbacks that
        write stdout events in addition to logging.

        We save the originals first — the sidecar overrides call them
        to preserve engine-side state tracking (mark_degraded, etc.).
        """
        self._orig_on_failure = self._engine._on_mcp_server_failure
        self._orig_on_recovery = self._engine._on_mcp_server_recovery
        self._orig_on_reconnect = self._engine._on_mcp_server_reconnect
        self._orig_on_boot_failures = self._engine._on_mcp_boot_failures

        self._engine._on_mcp_server_failure = self._on_mcp_server_failure
        self._engine._on_mcp_server_recovery = self._on_mcp_server_recovery
        self._engine._on_mcp_server_reconnect = self._on_mcp_server_reconnect
        self._engine._on_mcp_boot_failures = self._on_mcp_boot_failures

    # ------------------------------------------------------------------
    # State change -> stdout
    # ------------------------------------------------------------------

    def _emit_state_changed(self, thread_id: str, state) -> None:
        """Write thread_state_changed event to stdout."""
        write_response({
            "type": "thread_state_changed",
            "thread_id": thread_id,
            "state": state.value,
        })

    # ------------------------------------------------------------------
    # Agent readiness check (friendly inline message)
    # ------------------------------------------------------------------

    def _require_agent(self, cmd_name: str, *, thread_id: str | None = None) -> bool:
        """Return True if agent is ready; emit a friendly chat message if not."""
        if self._engine._agent is not None:
            return True
        msg = (
            "I'm not able to process requests yet — no model is configured."
            " Please set your API keys in **Settings \u2192 API Keys**."
        )
        msg_id = f"system-{cmd_name}"
        base = {"message_id": msg_id, "thread_id": thread_id}
        write_response({"type": "text_delta", "text": msg, **base})
        write_response({"type": "text_complete", "full_text": msg, **base})
        write_response({"type": "done", "thread_id": thread_id})
        return False

    # ------------------------------------------------------------------
    # Search indexing (sidecar-specific, not in engine)
    # ------------------------------------------------------------------

    def _make_emit_with_indexing(
        self, thread_id: str, content: str
    ):
        """Wrap write_response to trigger search indexing on stream done."""

        def emit(data: dict[str, Any]) -> None:
            write_response(data)
            if data.get("type") == "done":
                from icarus.session.search import safe_index_thread
                st = self._engine.stream_text.get(thread_id)
                ai_text = st.ai_text if st else ""
                asyncio.ensure_future(
                    safe_index_thread(thread_id, content, ai_text)
                )

        return emit

    # ------------------------------------------------------------------
    # Main loop
    # ------------------------------------------------------------------

    async def run(self) -> None:
        """Read JSON commands from stdin, dispatch, write events to stdout."""
        try:
            self._install_mcp_callbacks()
            await self.boot()

            while True:
                raw = await asyncio.get_running_loop().run_in_executor(
                    None, sys.stdin.buffer.readline,
                )
                if not raw:
                    break  # EOF — Tauri closed our stdin

                line = raw.decode("utf-8", errors="replace").strip()
                if not line:
                    continue

                try:
                    cmd = parse_command(line)
                except json.JSONDecodeError:
                    write_response({
                        "type": "error",
                        "message": f"Invalid JSON: {line!r}",
                    })
                    continue

                try:
                    await self._dispatch(cmd)
                except Exception:
                    logger.exception("Error handling command: %s", cmd.get("cmd"))
                    write_response({
                        "type": "error",
                        "cmd": cmd.get("cmd"),
                        "message": f"Internal error handling {cmd.get('cmd')}",
                    })

        except (asyncio.CancelledError, KeyboardInterrupt):
            logger.info("Sidecar shutting down")
        except Exception:
            logger.exception("Fatal error in sidecar main loop")
        finally:
            await self.shutdown()

    # ------------------------------------------------------------------
    # Command dispatch
    # ------------------------------------------------------------------

    _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",
        "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",
        "search_threads":      "_handle_search_threads",
        "browser_set_token":   "_handle_browser_set_token",
        "browser_get_status":  "_handle_browser_get_status",
    }

    async def _dispatch(self, cmd: dict[str, Any]) -> None:
        """Route a parsed command to its handler."""
        name = cmd.get("cmd")

        # HITL: resolve in-flight future (not a handler)
        if name == "approve":
            thread_id = cmd.get("thread_id")
            auto_all = cmd.get("auto_approve_all", False)
            # Backwards compat: no thread_id + sole pending HITL -> resolve it
            if not thread_id and len(self._engine._hitl_futures) == 1:
                thread_id = next(iter(self._engine._hitl_futures))
            if thread_id:
                self._engine.approve(thread_id, auto_approve_all=auto_all)
            return
        if name == "reject":
            thread_id = cmd.get("thread_id")
            if not thread_id and len(self._engine._hitl_futures) == 1:
                thread_id = next(iter(self._engine._hitl_futures))
            if thread_id:
                self._engine.reject(thread_id)
            return

        handler_name = self._HANDLERS.get(name)
        if not handler_name:
            write_response({"type": "error", "message": f"Unknown command: {name}"})
            return

        handler = getattr(self, handler_name)
        if asyncio.iscoroutinefunction(handler):
            await handler(cmd)
        else:
            handler(cmd)

    # ------------------------------------------------------------------
    # send — streaming (delegated to engine)
    # ------------------------------------------------------------------

    async def _handle_send(self, cmd: dict[str, Any]) -> None:
        """Start streaming a conversational turn via the engine."""
        thread_id = cmd.get("thread_id")
        content = cmd.get("content", "")

        # Create an emit callback that also triggers search indexing
        emit = self._make_emit_with_indexing(thread_id, content)

        try:
            await self._engine.send(thread_id, content, emit=emit)
        except RuntimeError:
            # Engine raises RuntimeError when agent is not ready
            # (after lazy switch attempt). Convert to friendly inline message.
            self._require_agent("send", thread_id=thread_id)

    # ------------------------------------------------------------------
    # cancel (delegated to engine)
    # ------------------------------------------------------------------

    async def _handle_cancel(self, cmd: dict[str, Any]) -> None:
        """Cancel streaming for a specific thread."""
        thread_id = cmd.get("thread_id")
        if thread_id:
            had_stream = thread_id in self._engine._streams
            await self._engine.cancel(thread_id)
            # If there was no active stream, the engine forced idle.
            # Write cancelled response so the frontend gets confirmation.
            # (Active streams emit their own cancelled event via the emit callback.)
            if not had_stream:
                write_response({"type": "cancelled", "thread_id": thread_id})
        else:
            write_response({"type": "cancelled"})

    # ------------------------------------------------------------------
    # Thread management (calls core/ directly)
    # ------------------------------------------------------------------

    @sidecar_handler(response_type="threads_list")
    async def _handle_list_threads(self, cmd):
        from icarus.session.threads import _DEFAULT_THREAD_LIMIT, list_threads

        limit = max(1, cmd.get("limit", _DEFAULT_THREAD_LIMIT))
        cursor = _parse_cursor(cmd)
        threads = await list_threads(
            include_message_count=True,
            limit=limit,
            cursor=cursor,
        )
        for t in threads:
            t["status"] = self._engine.state_manager.get_state(t["thread_id"]).value
        self._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
        )
        return {
            "threads": [dict(t) for t in threads],
            "next_cursor": next_cursor,
        }

    @sidecar_handler(required=["thread_id"])
    async def _handle_get_history(self, cmd, *, thread_id):
        from icarus.session.stream import build_stream_config

        config = build_stream_config(thread_id, "default")

        if self._engine.agent is not None:
            state = await self._engine.agent.aget_state(config)
            messages = (state.values or {}).get("messages", []) if state else []
        elif self._engine.checkpointer is not None:
            tup = await self._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]

        # If thread is actively streaming, append the current turn
        # so the frontend shows the full conversation including
        # the in-progress AI response.
        st = self._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]}",
                })
            ai_text = st.ai_text
            if st.ai_message_id and ai_text:
                serialized.append({
                    "role": "ai",
                    "content": ai_text,
                    "id": st.ai_message_id,
                })

        write_response({
            "type": "thread_history",
            "messages": serialized,
        })

        # Re-emit pending HITL request so the UI shows the approval
        # dialog when the user navigates back to an HITL thread.
        if thread_id in self._engine.hitl_requests:
            write_response({
                "type": "hitl_request",
                "action_requests": self._engine.hitl_requests[thread_id],
                "assistant_id": "default",
                "thread_id": thread_id,
            })

    @sidecar_handler(response_type="thread_deleted", required=["thread_id"])
    async def _handle_delete_thread(self, cmd, *, thread_id):
        from icarus.session.threads import delete_thread

        await delete_thread(thread_id)

        try:
            from icarus.session.search import delete_search_index
            await delete_search_index(thread_id)
        except Exception:
            logger.debug("Search index cleanup failed for %s", thread_id, exc_info=True)

        self._engine.known_threads.discard(thread_id)
        self._engine.titled_threads.discard(thread_id)
        self._engine.state_manager.clear(thread_id)
        return {"thread_id": thread_id}

    @sidecar_handler(response_type="thread_renamed", required=["thread_id", "title"])
    async def _handle_rename_thread(self, cmd, *, thread_id, title):
        from icarus.session.threads import rename_thread

        await rename_thread(thread_id, title)
        return {"thread_id": thread_id, "title": title}

    @sidecar_handler(response_type="search_results")
    async def _handle_search_threads(self, cmd):
        from icarus.session.search import search_threads

        query = cmd.get("query", "")
        limit = max(1, cmd.get("limit", 20))
        cursor = _parse_cursor(cmd)
        result = await search_threads(query=query, limit=limit, cursor=cursor)

        for r in result["results"]:
            r["status"] = self._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
        )
        return {"results": result["results"], "query": query, "next_cursor": next_cursor}

    # ------------------------------------------------------------------
    # Workspace (delegated to engine — mutates engine state)
    # ------------------------------------------------------------------

    async def _handle_set_workspace(self, cmd: dict[str, Any]) -> None:
        """Change workspace directory."""
        path = cmd.get("path", "").strip()
        if not path:
            write_response({"type": "error", "message": "set_workspace requires path"})
            return
        try:
            result = await self._engine.set_workspace(path)
            write_response(result)
        except Exception as exc:
            logger.exception("Failed: set_workspace")
            write_response({"type": "error", "message": str(exc)})

    # ------------------------------------------------------------------
    # Model management
    # ------------------------------------------------------------------

    @sidecar_handler(response_type="models_list")
    def _handle_list_models(self, cmd):
        from icarus.session.model import list_models

        info = list_models()
        # If a lazy switch is pending, report it as current so the
        # frontend doesn't revert the optimistic UI update.
        current = self._engine.current_model_spec() or info.current
        return {"models": info.models, "current": current, "default": info.default}

    async def _handle_set_model(self, cmd: dict[str, Any]) -> None:
        """Set the active model (lazy switch, delegated to engine)."""
        model = cmd.get("model", "").strip()
        if not model:
            write_response({"type": "error", "message": "set_model requires model"})
            return
        try:
            result = await self._engine.set_model(model)
            write_response(result)
        except Exception as exc:
            logger.exception("Failed: set_model")
            write_response({"type": "error", "message": str(exc)})

    @sidecar_handler(required=["model"])
    async def _handle_set_default_model(self, cmd, *, model):
        from icarus.session.model import set_default

        default_result = set_default(model)
        if not default_result.ok:
            raise HandlerError(default_result.error)

        # Lazy switch — save as pending, actual switch happens on next send.
        if self._engine.current_model_spec() != default_result.display_name:
            self._engine._pending_model = default_result.display_name

        write_response({"type": "default_model_set", "model": default_result.display_name})

    # ------------------------------------------------------------------
    # Credentials
    # ------------------------------------------------------------------

    @sidecar_handler(response_type="credentials_info")
    def _handle_get_credentials(self, cmd):
        from icarus.config.model_config import get_provider_registry

        return {"providers": get_provider_registry()}

    async def _handle_set_credentials(self, cmd: dict[str, Any]) -> None:
        """Save credentials and rebuild agent (delegated to engine)."""
        credentials = cmd.get("credentials", {})
        provider_models = cmd.get("provider_models")
        provider_endpoints = cmd.get("provider_endpoints")
        try:
            result = await self._engine.set_credentials(
                credentials, provider_models, provider_endpoints
            )
            write_response(result)
        except Exception as exc:
            logger.exception("Failed: set_credentials")
            write_response({"type": "error", "message": str(exc)})

    # ------------------------------------------------------------------
    # Skills (calls core/ directly)
    # ------------------------------------------------------------------

    @sidecar_handler(response_type="skills_list")
    def _handle_list_skills(self, cmd):
        """List all skills from built-in, user, and project directories."""
        from icarus.config.app_config import settings
        from icarus.skills.load import list_skills

        skills = list_skills(
            built_in_skills_dir=settings.get_built_in_skills_dir(),
            user_skills_dir=settings.get_user_skills_dir("default"),
            project_skills_dir=settings.get_project_skills_dir(),
            user_agent_skills_dir=settings.get_user_agent_skills_dir(),
            project_agent_skills_dir=settings.get_project_agent_skills_dir(),
            plugins_base_dir=settings.get_plugins_base_dir(),
        )
        return {
            "skills": [
                {
                    "name": s["name"],
                    "description": s.get("description", ""),
                    "source": s.get("source", "unknown"),
                    "path": s.get("path", ""),
                }
                for s in skills
            ],
        }

    @sidecar_handler(response_type="skill_created", required=["name"], validate=[VALIDATE_SKILL_NAME("name")])
    def _handle_create_skill(self, cmd, *, name):
        """Create a new skill with a SKILL.md file."""
        from icarus.config.app_config import settings
        from icarus.skills.validation import validate_skill_path

        description = cmd.get("description", "").strip()
        instructions = cmd.get("instructions", "").strip()

        skills_dir = settings.ensure_user_skills_dir("default")
        skill_dir = skills_dir / name

        is_valid_path, path_error = validate_skill_path(skill_dir, skills_dir)
        if not is_valid_path:
            raise HandlerError(path_error)

        try:
            skill_dir.mkdir(parents=True, exist_ok=False)
        except FileExistsError:
            raise HandlerError(f"Skill '{name}' already exists")

        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"
        )
        content = (
            f"---\n"
            f"name: {name}\n"
            f'description: "{desc_line}"\n'
            f"---\n\n"
            f"{body}\n"
        )
        (skill_dir / "SKILL.md").write_text(content)

        return {"name": name, "path": str(skill_dir)}

    @sidecar_handler(response_type="skill_deleted", required=["name"], validate=[VALIDATE_SKILL_NAME("name")])
    def _handle_delete_skill(self, cmd, *, name):
        """Delete a user-created skill."""
        from icarus.skills.installer import uninstall_skill

        uninstall_skill(name)
        return {"name": name}

    @sidecar_handler(response_type="skill_content", required=["name"], validate=[VALIDATE_SKILL_NAME("name")])
    def _handle_get_skill_content(self, cmd, *, name):
        """Read the SKILL.md content for a specific skill."""
        from icarus.config.app_config import settings
        from icarus.skills.validation import validate_plugin_name

        source = cmd.get("source", "user")

        if source == "built-in":
            skill_dir = settings.get_built_in_skills_dir() / name
        elif source == "project":
            project_dir = settings.get_project_skills_dir()
            if not project_dir:
                raise HandlerError("No project directory found")
            skill_dir = project_dir / name
        elif source.startswith("plugin:"):
            plugin_name = source[len("plugin:"):]
            is_valid_plugin, plugin_error = validate_plugin_name(plugin_name)
            if not is_valid_plugin:
                raise HandlerError(f"Invalid plugin name: {plugin_error}")
            skill_dir = settings.get_plugin_skills_dir(plugin_name) / name
        else:
            skill_dir = settings.get_user_skills_dir("default") / name

        skill_md = skill_dir / "SKILL.md"
        if not skill_md.exists():
            raise HandlerError(f"SKILL.md not found for '{name}'")

        content = skill_md.read_text(encoding="utf-8")
        return {"name": name, "content": content, "path": str(skill_dir)}

    # ------------------------------------------------------------------
    # Plugins (calls core/ directly)
    # ------------------------------------------------------------------

    @sidecar_handler(response_type="plugin_added", required=["url"])
    async def _handle_add_plugin(self, cmd, *, url):
        """Add a skill registry (plugin marketplace)."""
        from icarus.skills.registry_config import RegistryEntry, SkillsConfig
        from icarus.skills.validation import validate_plugin_name

        token = cmd.get("token", "").strip()

        # Normalize: "owner/repo" -> "github:owner/repo"
        if "/" in url and not url.startswith("github:"):
            url = f"github:{url}"

        name = url.split("/")[-1] if "/" in url else url

        is_valid, error_msg = validate_plugin_name(name)
        if not is_valid:
            raise HandlerError(f"Invalid plugin name: {error_msg}")

        # Set token in process env so fetch_index can authenticate,
        # but don't persist to .env yet — only write to disk after
        # successful install to avoid orphaned credentials on failure.
        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)

            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,
                        )
                        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
                save_credentials({token_env: token})

            config = SkillsConfig.load()
            config.add_registry(entry)
            config.save()

            return {"name": name, "url": url, "installed_skills": installed}
        except Exception:
            if token_env:
                os.environ.pop(token_env, None)
            raise

    @sidecar_handler(response_type="plugins_list")
    def _handle_list_plugins(self, cmd):
        """List configured skill registries (plugins)."""
        from icarus.skills.registry_config import SkillsConfig

        config = SkillsConfig.load()
        return {
            "plugins": [
                {"name": r.name, "url": r.url, "has_token": bool(r.token_env)}
                for r in config.registries
                if r.name != "icarus"
            ],
        }

    @sidecar_handler(response_type="plugin_removed", required=["name"], validate=[VALIDATE_PLUGIN_NAME("name")])
    async def _handle_remove_plugin(self, cmd, *, name):
        """Remove a skill registry (plugin) and delete its skill files."""
        import shutil

        from icarus.config.app_config import settings
        from icarus.skills.registry_config import SkillsConfig

        # Delete files first, then update config — if rmtree fails,
        # the registry entry stays so the user can retry removal.
        plugin_dir = settings.get_plugins_base_dir() / name
        if plugin_dir.exists():
            await asyncio.to_thread(shutil.rmtree, plugin_dir)

        config = SkillsConfig.load()
        config.remove_registry(name)
        config.save()

        return {"name": name}

    @sidecar_handler(response_type="plugin_skills_list", required=["plugin"], validate=[VALIDATE_PLUGIN_NAME("plugin")])
    async def _handle_fetch_plugin_skills(self, cmd, *, plugin):
        """Fetch available skills from a plugin's registry."""
        from icarus.config.app_config import settings
        from icarus.skills.registry import fetch_index
        from icarus.skills.registry_config import SkillsConfig

        config = SkillsConfig.load()
        entry = next((r for r in config.registries if r.name == plugin), None)
        if not entry:
            raise HandlerError(f"Plugin '{plugin}' not found")

        def _sync_fetch():
            index = fetch_index(entry)
            plugin_skills_dir = settings.get_plugin_skills_dir(plugin)
            installed = {
                d.name for d in plugin_skills_dir.iterdir() if d.is_dir()
            } if plugin_skills_dir.exists() else set()
            return index, installed

        index, installed = await asyncio.to_thread(_sync_fetch)

        return {
            "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 server management — calls core/ directly)
    # ------------------------------------------------------------------

    def _build_connector_list(self, config: dict[str, Any] | None = None) -> list[dict[str, Any]]:
        """Build connector info list from config + MCPManager status."""
        from icarus.mcp.config import list_servers, read_mcp_config

        if config is None:
            config = read_mcp_config(self._engine._mcp_config_path())
        mgr = self._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, []),
            }
            cfg = entry.config
            for key in ("command", "args", "env", "url", "headers"):
                if key in cfg:
                    info[key] = cfg[key]
            result.append(info)
        return result

    async def _mutate_and_reload(self, mutate_fn, name, *args):
        """Read config, apply mutation, write, reload MCP, return connector list."""
        from icarus.mcp.config import read_mcp_config, write_mcp_config

        config_path = self._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 self._engine.reload_mcp()
        except Exception:
            logger.exception("MCP reload failed after mutating connector %s", name)
        # Read fresh config — reload_mcp / _auto_disable may have changed it
        fresh = await asyncio.to_thread(read_mcp_config, config_path)
        return {"connectors": self._build_connector_list(fresh)}

    @sidecar_handler(response_type="connectors_list")
    async def _handle_list_connectors(self, cmd):
        """List all MCP connectors with their status and tools."""
        from icarus.mcp.config import read_mcp_config

        config = await asyncio.to_thread(
            read_mcp_config, self._engine._mcp_config_path()
        )
        return {"connectors": self._build_connector_list(config)}

    @sidecar_handler(response_type="connector_added", required=["name"])
    async def _handle_add_connector(self, cmd, *, name):
        """Probe connection first, then write config and reload."""
        from icarus.mcp import MCPManager
        from icarus.mcp.config import add_server

        config_data = cmd.get("config", {})
        if not config_data:
            raise HandlerError("add_connector requires config")

        target = config_data.get("url") or config_data.get("command", "server")
        try:
            await MCPManager.probe(config_data)
        except Exception:
            raise HandlerError(f"Could not reach {target} — make sure the server is running")

        return await self._mutate_and_reload(add_server, name, config_data)

    @sidecar_handler(response_type="connector_updated", required=["name"])
    async def _handle_update_connector(self, cmd, *, name):
        """Update an existing MCP connector and reload."""
        from icarus.mcp.config import update_server

        config_data = cmd.get("config", {})
        if not config_data:
            raise HandlerError("update_connector requires config")

        return await self._mutate_and_reload(update_server, name, config_data)

    @sidecar_handler(response_type="connector_removed", required=["name"])
    async def _handle_remove_connector(self, cmd, *, name):
        """Remove an MCP connector and reload."""
        from icarus.mcp.config import remove_server

        return await self._mutate_and_reload(remove_server, name)

    @sidecar_handler(response_type="connector_toggled", required=["name"])
    async def _handle_toggle_connector(self, cmd, *, name):
        """Toggle a connector between enabled and disabled, then reload."""
        from icarus.mcp.config import toggle_server

        return await self._mutate_and_reload(toggle_server, name)

    @sidecar_handler(response_type="connector_retried", required=["name"])
    async def _handle_retry_connector(self, cmd, *, name):
        """Re-probe a degraded/error connector and reload if reachable."""
        from icarus.mcp.config import read_mcp_config

        config_path = self._engine._mcp_config_path()
        config = await asyncio.to_thread(read_mcp_config, config_path)

        # Find the server config in either section
        cfg = config.get("mcpServers", {}).get(name) or config.get("_disabled", {}).get(name)
        if not cfg:
            raise HandlerError(f"Connector '{name}' not found")

        from icarus.mcp import MCPManager

        target = cfg.get("url") or cfg.get("command", "server")
        try:
            await MCPManager.probe(cfg)
        except Exception:
            raise HandlerError(f"Still cannot reach {target}")

        # If it was disabled, re-enable it first
        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 self._engine.reload_mcp()
        fresh = await asyncio.to_thread(read_mcp_config, config_path)
        return {"connectors": self._build_connector_list(fresh)}

    # ------------------------------------------------------------------
    # Browser connector handlers
    # ------------------------------------------------------------------

    @sidecar_handler(response_type="browser_token_set", required=["token"])
    async def _handle_browser_set_token(self, cmd, *, token):
        """Save the Playwright extension token to ~/.icarus/browser_token."""
        from icarus.tools.browser import (
            BROWSER_TOKEN_FILE,
            invalidate_browser_env,
            strip_token_prefix,
        )

        clean = strip_token_prefix(token.strip())

        def _save():
            BROWSER_TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
            BROWSER_TOKEN_FILE.write_text(clean)

        await asyncio.to_thread(_save)
        invalidate_browser_env()
        return {"saved": True}

    @sidecar_handler(response_type="browser_status")
    async def _handle_browser_get_status(self, cmd):
        """Check if browser token exists and playwright-cli is available."""
        from icarus.tools.browser import (
            BROWSER_TOKEN_FILE,
            create_browser_tools,
            find_playwright_cli,
        )

        def _check() -> tuple[bool, str, bool, list[str]]:
            token_file = BROWSER_TOKEN_FILE
            has_token = token_file.exists() and bool(token_file.read_text().strip())
            cli_path = find_playwright_cli()
            if Path(cli_path).is_file():
                cli_available = True
            else:
                cli_available = bool(shutil.which(cli_path))
            tool_names = [t.__name__ for t in create_browser_tools()]
            return has_token, cli_path, cli_available, tool_names

        has_token, cli_path, cli_available, tools = await asyncio.to_thread(_check)
        return {
            "has_token": has_token,
            "cli_available": cli_available,
            "cli_path": cli_path,
            "tools": tools,
        }


# ----------------------------------------------------------------------
# Entry point
# ----------------------------------------------------------------------

def main() -> None:
    """Entry point.  IPC mode if stdin is piped."""
    if sys.stdin.isatty():
        print("This is the Tauri sidecar. Use 'icarus' for the CLI.", file=sys.stderr)
        sys.exit(1)
    else:
        # IPC mode — run sidecar event loop
        # Load ~/.icarus/.env BEFORE any icarus imports so credentials
        # are in os.environ when app_config.py runs dotenv.load_dotenv()
        # at module level.  We must NOT import from icarus here —
        # that would trigger the import chain and read empty env.
        import dotenv
        dotenv.load_dotenv(Path.home() / ".icarus" / ".env", override=True)

        log_level = os.environ.get("ICARUS_LOG_LEVEL", "WARNING").upper()
        logging.basicConfig(
            level=getattr(logging, log_level, logging.WARNING),
            format="%(asctime)s %(name)s %(levelname)s %(message)s",
            stream=sys.stderr,
        )
        asyncio.run(Sidecar().run())


if __name__ == "__main__":
    main()
