"""MobKit runtime object — the running instance returned by the builder."""
from __future__ import annotations

import asyncio
import base64
import hashlib
import hmac
import itertools
import json
import logging
import time
from typing import Any, AsyncIterator
from urllib import request as urllib_request

_log = logging.getLogger("meerkat_mobkit")

from .agent_builder import CallbackDispatcher, SessionAgentBuilder
from .errors import NotConnectedError, RpcError, TransportError
from .events import AgentEvent, MobEvent
from ._sse import SseEvent, parse_sse_stream
from ._transport import PersistentTransport
from .models import DiscoverySpec
from .types import (
    CallToolResult,
    CapabilitiesResult,
    DeliveryHistoryResult,
    DeliveryResult,
    GatingAuditEntry,
    GatingDecisionResult,
    GatingEvaluateResult,
    GatingPendingEntry,
    MemberSnapshot,
    MemoryIndexResult,
    MemoryQueryResult,
    MemoryStoreInfo,
    ModelsCatalogResult,
    ReconcileEdgesReport,
    ReconcileResult,
    RediscoverReport,
    RoutingResolution,
    RuntimeRouteResult,
    SendMessageResult,
    SpawnResult,
    StatusResult,
    SubscribeResult,
)

from ._client import _build_request as _rpc_request

_request_counter = itertools.count(1)


def _next_request_id(method: str) -> str:
    return f"{method}:{next(_request_counter)}"


class MobKitRuntime:
    """Running MobKit runtime instance.

    Supports both context-manager and explicit lifecycle patterns::

        # Context manager
        async with await MobKit.builder().mob("mob.toml").build() as rt:
            handle = rt.mob_handle()
            status = await handle.status()

        # Explicit lifecycle
        rt = await MobKit.builder().mob("mob.toml").build()
        await rt.connect()
        ...
        await rt.shutdown()
    """

    def __init__(self, config: Any, transport: PersistentTransport | None = None):
        self._config = config
        self._transport = transport
        self._running = False
        self._dispatcher = CallbackDispatcher()
        self._rust_http_base: str | None = None

    async def __aenter__(self) -> MobKitRuntime:
        if not self._running:
            await self.connect()
        return self

    async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        await self.shutdown()

    @classmethod
    async def _create(cls, config: Any) -> MobKitRuntime:
        runtime = cls(config)
        await runtime._bootstrap()
        return runtime

    async def connect(self) -> None:
        """Explicitly connect to the runtime. Idempotent."""
        if self._running:
            return
        await self._bootstrap()

    async def _bootstrap(self) -> None:
        if self._config.gateway_bin:
            self._transport = PersistentTransport(self._config.gateway_bin)
            # Register builder FIRST — init may trigger callback/build_agent
            if self._config.session_builder and isinstance(
                self._config.session_builder, SessionAgentBuilder
            ):
                self._dispatcher.register_builder(self._config.session_builder)
            if self._config.error_callback is not None:
                self._dispatcher.register_error_callback(self._config.error_callback)
            self._transport.set_callback_handler(self._dispatcher.handle_callback)
            self._transport.start()
            if not self._transport.is_running():
                raise TransportError(
                    f"gateway binary failed to start: {self._config.gateway_bin}"
                )
            try:
                init_result = await self._rpc("mobkit/init", self._build_init_params())
                if isinstance(init_result, dict):
                    self._rust_http_base = init_result.get("http_base_url")
                    if not self._rust_http_base:
                        _log.warning(
                            "mobkit/init did not return http_base_url — "
                            "SSE event streaming unavailable"
                        )
            except Exception:
                if self._transport is not None and not self._transport.is_running():
                    raise TransportError("gateway process died during bootstrap")
                raise TransportError("mobkit/init failed — runtime could not be initialized")
        elif self._config.session_builder and isinstance(
            self._config.session_builder, SessionAgentBuilder
        ):
            self._dispatcher.register_builder(self._config.session_builder)
        else:
            _log.warning(
                "MobKit runtime started without gateway or session builder — "
                "RPC calls will fail with NotConnectedError"
            )
        self._running = True

    def _build_init_params(self) -> dict[str, Any]:
        """Build init params dict from builder config for mobkit/init RPC."""
        params: dict[str, Any] = {}
        if self._config.mob_config_path:
            with open(self._config.mob_config_path) as f:
                params["mob_config"] = f.read()
        if self._config.modules:
            params["modules"] = self._config.modules
        params["has_session_builder"] = bool(self._config.session_builder)
        runtime_options: dict[str, Any] = {}
        if self._config.gating_config_path:
            runtime_options["gating_config_path"] = self._config.gating_config_path
        if self._config.routing_config_path:
            runtime_options["routing_config_path"] = self._config.routing_config_path
        if self._config.scheduling_files:
            runtime_options["scheduling_files"] = self._config.scheduling_files
        if self._config.memory_config:
            runtime_options["memory_config"] = _serialize_config(self._config.memory_config)
        if self._config.auth_config:
            runtime_options["auth_config"] = _serialize_config(self._config.auth_config)
        if self._config.event_log:
            runtime_options["event_log"] = _serialize_config(self._config.event_log)
        params["runtime_options"] = runtime_options
        return params

    def _rpc_sync(self, method: str, params: dict[str, Any] | None = None) -> Any:
        if self._transport is None:
            raise NotConnectedError("runtime not started — no transport available")
        rid = _next_request_id(method)
        response = self._transport.send_sync(_rpc_request(rid, method, params))
        if "error" in response:
            err = response["error"]
            raise RpcError(
                code=err.get("code", -1),
                message=err.get("message", str(err)),
                request_id=rid,
                method=method,
            )
        return response.get("result")

    async def _rpc(self, method: str, params: dict[str, Any] | None = None) -> Any:
        if self._transport is None:
            raise NotConnectedError("runtime not started — no transport available")
        rid = _next_request_id(method)
        response = await self._transport.send_async(_rpc_request(rid, method, params))
        if "error" in response:
            err = response["error"]
            raise RpcError(
                code=err.get("code", -1),
                message=err.get("message", str(err)),
                request_id=rid,
                method=method,
            )
        return response.get("result")

    @property
    def rust_http_base_url(self) -> str | None:
        return self._rust_http_base

    def set_rust_http_base(self, url: str) -> None:
        self._rust_http_base = url

    def mob_handle(self) -> MobHandle:
        return MobHandle(self)

    def sse_bridge(self) -> SseBridge:
        return SseBridge(self)

    def asgi(
        self,
        *,
        console: bool = True,
        auth: Any | None = None,
        extra_routes: Any | None = None,
    ) -> AsgiApp:
        return AsgiApp(
            runtime=self,
            console=console,
            auth_config=auth,
            fallback_app=extra_routes,
        )

    async def serve(
        self,
        app: Any = None,
        *,
        host: str = "0.0.0.0",
        port: int = 8080,
    ) -> None:
        if app is None:
            app = self.asgi()
        try:
            import uvicorn
            config = uvicorn.Config(app, host=host, port=port, log_level="info")
            server = uvicorn.Server(config)
            await server.serve()
        except ImportError:
            stop = asyncio.Event()
            import signal
            loop = asyncio.get_running_loop()
            for sig in (signal.SIGINT, signal.SIGTERM):
                loop.add_signal_handler(sig, stop.set)
            await stop.wait()
        finally:
            await self.shutdown()

    async def shutdown(self) -> None:
        self._running = False
        if self._transport is not None:
            self._transport.stop()
            self._transport = None

    @property
    def is_running(self) -> bool:
        return self._running


class MobHandle:
    """Proxy for the Meerkat MobHandle API via JSON-RPC.

    Returns typed result objects instead of raw dicts.
    """

    def __init__(self, runtime: MobKitRuntime):
        self._runtime = runtime

    async def status(self) -> StatusResult:
        """Return the current runtime status."""
        raw = await self._runtime._rpc("mobkit/status")
        return StatusResult.from_dict(raw)

    async def capabilities(self) -> CapabilitiesResult:
        """Return the runtime's advertised capabilities."""
        raw = await self._runtime._rpc("mobkit/capabilities")
        return CapabilitiesResult.from_dict(raw)

    async def spawn(self, spec: DiscoverySpec) -> SpawnResult:
        """Spawn a mob member from a full discovery spec."""
        raw = await self._runtime._rpc("mobkit/spawn_member", spec.to_dict())
        return SpawnResult.from_dict(raw)

    async def spawn_member(self, module_id: str) -> SpawnResult:
        """Spawn a mob member by module ID."""
        raw = await self._runtime._rpc("mobkit/spawn_member", {"module_id": module_id})
        return SpawnResult.from_dict(raw)

    async def reconcile(self, modules: list[str]) -> ReconcileResult:
        """Reconcile the mob to match the given module list."""
        raw = await self._runtime._rpc("mobkit/reconcile", {"modules": modules})
        return ReconcileResult.from_dict(raw)

    async def subscribe_events(
        self,
        scope: str = "mob",
        last_event_id: str | None = None,
        agent_id: str | None = None,
    ) -> SubscribeResult:
        """Subscribe to runtime events with an optional scope filter."""
        params: dict[str, Any] = {"scope": scope}
        if last_event_id is not None:
            params["last_event_id"] = last_event_id
        if agent_id is not None:
            params["agent_id"] = agent_id
        raw = await self._runtime._rpc("mobkit/events/subscribe", params)
        return SubscribeResult.from_dict(raw)

    async def resolve_routing(self, recipient: str, **kwargs: Any) -> RoutingResolution:
        """Resolve a routing target for the given recipient."""
        raw = await self._runtime._rpc(
            "mobkit/routing/resolve", {"recipient": recipient, **kwargs}
        )
        return RoutingResolution.from_dict(raw)

    async def send_delivery(self, **kwargs: Any) -> DeliveryResult:
        """Send a delivery payload through the routing layer."""
        raw = await self._runtime._rpc("mobkit/delivery/send", kwargs)
        return DeliveryResult.from_dict(raw)

    async def memory_query(self, query: str, **kwargs: Any) -> MemoryQueryResult:
        """Query a memory store by natural-language assertion."""
        raw = await self._runtime._rpc("mobkit/memory/query", {"query": query, **kwargs})
        return MemoryQueryResult.from_dict(raw)

    async def call_tool(
        self, module_id: str, tool: str, arguments: dict[str, Any] | None = None
    ) -> CallToolResult:
        """Call an MCP tool on a loaded module."""
        params: dict[str, Any] = {"module_id": module_id, "tool": tool}
        if arguments:
            params["arguments"] = arguments
        raw = await self._runtime._rpc("mobkit/call_tool", params)
        return CallToolResult.from_dict(raw)

    async def models_catalog(self) -> ModelsCatalogResult:
        """Return the curated model catalog with provider defaults."""
        raw = await self._runtime._rpc("mobkit/models/catalog")
        return ModelsCatalogResult.from_dict(raw)

    def tool_caller(self, module_id: str) -> ToolCaller:
        """Return a callable scoped to one MCP module.

        Usage::

            gmail = mob_handle.tool_caller("google-workspace")
            messages = await gmail("gmail_search", query="is:unread")
        """
        return ToolCaller(self, module_id)

    # -----------------------------------------------------------------
    # Primary API — comms, observation, control plane
    # -----------------------------------------------------------------

    async def ensure_member(
        self, member_id: str, profile: str, **kwargs: Any
    ) -> MemberSnapshot:
        """Ensure a mob member exists, spawning it if missing.

        Idempotent — returns the member snapshot whether it was just spawned
        or already existed. Use before ``send()`` when handling first contact
        from an unknown user (e.g. new Slack DM).

        Args:
            member_id: Meerkat ID for the member.
            profile: Profile name from mob.toml to spawn with.
            **kwargs: Optional fields (labels, context, resume_session_id,
                      additional_instructions).
        """
        params: dict[str, Any] = {"profile": profile, "meerkat_id": member_id}
        if "labels" in kwargs:
            params["labels"] = kwargs["labels"]
        if "context" in kwargs:
            params["context"] = kwargs["context"]
        if "resume_session_id" in kwargs:
            params["resume_session_id"] = kwargs["resume_session_id"]
        if "additional_instructions" in kwargs:
            params["additional_instructions"] = kwargs["additional_instructions"]
        raw = await self._runtime._rpc("mobkit/ensure_member", params)
        return MemberSnapshot.from_dict(raw)

    async def find_members(
        self, label_key: str, label_value: str
    ) -> list[MemberSnapshot]:
        """Find members matching a label key-value pair.

        Example::

            # Find all initiative agents
            initiatives = await handle.find_members("agent_type", "initiative")

            # Find the agent for a specific owner
            agents = await handle.find_members("owner_id", "user-123")
            if agents:
                meerkat_id = agents[0].meerkat_id
        """
        raw = await self._runtime._rpc(
            "mobkit/find_members",
            {"label_key": label_key, "label_value": label_value},
        )
        if isinstance(raw, list):
            return [MemberSnapshot.from_dict(m) for m in raw]
        return []

    async def rediscover(self) -> RediscoverReport | None:
        """Reset the mob and re-run discovery + edge reconciliation.

        Sequence: reset mob (retire all, clear state) → re-run Discovery →
        spawn discovered members → reconcile edges.

        Returns ``None`` if no Discovery was configured on the builder.

        Use for "nuke everything and start fresh" scenarios — e.g. a config
        reload, admin reset command, or recovery from a bad state.
        """
        raw = await self._runtime._rpc("mobkit/rediscover")
        if isinstance(raw, dict) and "status" in raw:
            return None
        return RediscoverReport.from_dict(raw)

    async def reconcile_edges(self) -> ReconcileEdgesReport:
        """Re-run edge discovery and reconcile dynamic peer edges.

        Refreshes the active roster, runs the configured ``EdgeDiscovery``,
        and applies wire/unwire operations to match the desired topology.

        Only useful if ``EdgeDiscovery`` was configured on the builder.
        Returns an empty report if no edge discovery is configured.
        """
        raw = await self._runtime._rpc("mobkit/reconcile_edges")
        return ReconcileEdgesReport.from_dict(raw)

    async def send(
        self,
        member_id: str,
        message: str | None = None,
        *,
        content: list[dict[str, Any]] | None = None,
    ) -> SendMessageResult:
        """Send a message to a mob member and return the accepting session.

        Args:
            member_id: Target member ID.
            message: Plain text message (simple path).
            content: Multimodal content blocks, e.g.
                ``[{"type": "text", "text": "describe this"},
                  {"type": "image", "media_type": "image/png", "data": "<base64>"}]``

        Either ``message`` or ``content`` must be provided. If both are given,
        ``message`` takes precedence.
        """
        params: dict[str, Any] = {"member_id": member_id}
        if message is not None:
            params["message"] = message
        elif content is not None:
            params["content"] = content
        else:
            raise ValueError("either message or content must be provided")
        raw = await self._runtime._rpc("mobkit/send_message", params)
        return SendMessageResult.from_dict(raw)

    async def query_events(
        self,
        *,
        since_ms: int | None = None,
        until_ms: int | None = None,
        member_id: str | None = None,
        event_types: list[str] | None = None,
        limit: int | None = None,
        after_seq: int | None = None,
    ) -> list[PersistedEvent]:
        """Query persisted operational events from the event log.

        Returns an empty list if no event log is configured.
        """
        from .types import EventQuery, PersistedEvent
        query = EventQuery(
            since_ms=since_ms,
            until_ms=until_ms,
            member_id=member_id,
            event_types=event_types or [],
            limit=limit,
            after_seq=after_seq,
        )
        raw = await self._runtime._rpc("mobkit/query_events", query.to_dict())
        if isinstance(raw, dict) and raw.get("status") == "no_event_log_configured":
            return []
        if isinstance(raw, list):
            return [PersistedEvent.from_dict(e) for e in raw]
        return []

    # -----------------------------------------------------------------
    # Roster — member lifecycle
    # -----------------------------------------------------------------

    async def list_members(self) -> list[MemberSnapshot]:
        """List all members in the mob roster (active + retiring)."""
        raw = await self._runtime._rpc("mobkit/list_members")
        if isinstance(raw, list):
            return [MemberSnapshot.from_dict(m) for m in raw]
        return []

    async def get_member(self, member_id: str) -> MemberSnapshot:
        """Get a single member snapshot by ID. Raises RpcError if not found."""
        raw = await self._runtime._rpc("mobkit/get_member", {"member_id": member_id})
        return MemberSnapshot.from_dict(raw)

    async def retire_member(self, member_id: str) -> None:
        """Retire a member (transition to retiring state)."""
        await self._runtime._rpc("mobkit/retire_member", {"member_id": member_id})

    async def respawn_member(self, member_id: str) -> None:
        """Respawn a member (replace with fresh instance)."""
        await self._runtime._rpc("mobkit/respawn_member", {"member_id": member_id})

    # -----------------------------------------------------------------
    # Routing — route management
    # -----------------------------------------------------------------

    async def list_routes(self) -> list[RuntimeRouteResult]:
        """List all configured runtime routes."""
        raw = await self._runtime._rpc("mobkit/routing/routes/list")
        routes = raw.get("routes", []) if isinstance(raw, dict) else []
        return [RuntimeRouteResult.from_dict(r) for r in routes]

    async def add_route(
        self,
        route_key: str,
        recipient: str,
        sink: str,
        target_module: str,
        channel: str | None = None,
    ) -> RuntimeRouteResult:
        """Add or update a route. Overwrites on duplicate route_key."""
        params: dict[str, Any] = {
            "route_key": route_key,
            "recipient": recipient,
            "sink": sink,
            "target_module": target_module,
        }
        if channel is not None:
            params["channel"] = channel
        raw = await self._runtime._rpc("mobkit/routing/routes/add", params)
        route_data = raw.get("route", raw) if isinstance(raw, dict) else raw
        return RuntimeRouteResult.from_dict(route_data)

    async def delete_route(self, route_key: str) -> RuntimeRouteResult:
        """Delete a route by key. Returns the deleted route."""
        raw = await self._runtime._rpc("mobkit/routing/routes/delete", {"route_key": route_key})
        deleted_data = raw.get("deleted", raw) if isinstance(raw, dict) else raw
        return RuntimeRouteResult.from_dict(deleted_data)

    # -----------------------------------------------------------------
    # Delivery — history
    # -----------------------------------------------------------------

    async def delivery_history(
        self,
        recipient: str | None = None,
        sink: str | None = None,
        limit: int = 20,
    ) -> DeliveryHistoryResult:
        """Query delivery history with optional recipient/sink filters."""
        params: dict[str, Any] = {"limit": limit}
        if recipient is not None:
            params["recipient"] = recipient
        if sink is not None:
            params["sink"] = sink
        raw = await self._runtime._rpc("mobkit/delivery/history", params)
        return DeliveryHistoryResult.from_dict(raw)

    # -----------------------------------------------------------------
    # Gating — policy enforcement
    # -----------------------------------------------------------------

    async def gating_evaluate(
        self,
        action: str,
        actor_id: str,
        **kwargs: Any,
    ) -> GatingEvaluateResult:
        """Evaluate an action against configured gating policies."""
        params: dict[str, Any] = {"action": action, "actor_id": actor_id, **kwargs}
        raw = await self._runtime._rpc("mobkit/gating/evaluate", params)
        return GatingEvaluateResult.from_dict(raw)

    async def gating_pending(self) -> list[GatingPendingEntry]:
        """List gating decisions awaiting approval."""
        raw = await self._runtime._rpc("mobkit/gating/pending")
        entries = raw.get("pending", []) if isinstance(raw, dict) else []
        return [GatingPendingEntry.from_dict(e) for e in entries]

    async def gating_decide(
        self,
        pending_id: str,
        decision: str,
        approver_id: str,
        **kwargs: Any,
    ) -> GatingDecisionResult:
        """Approve or reject a pending gating action."""
        params: dict[str, Any] = {
            "pending_id": pending_id,
            "decision": decision,
            "approver_id": approver_id,
            **kwargs,
        }
        raw = await self._runtime._rpc("mobkit/gating/decide", params)
        return GatingDecisionResult.from_dict(raw)

    async def gating_audit(self, limit: int = 100) -> list[GatingAuditEntry]:
        """Query the gating audit log."""
        raw = await self._runtime._rpc("mobkit/gating/audit", {"limit": limit})
        entries = raw.get("entries", []) if isinstance(raw, dict) else []
        return [GatingAuditEntry.from_dict(e) for e in entries]

    # -----------------------------------------------------------------
    # Memory — store management
    # -----------------------------------------------------------------

    async def memory_stores(self) -> list[MemoryStoreInfo]:
        """List available memory stores with record counts."""
        raw = await self._runtime._rpc("mobkit/memory/stores")
        stores = raw.get("stores", []) if isinstance(raw, dict) else []
        return [MemoryStoreInfo.from_dict(s) for s in stores]

    async def memory_index(
        self,
        entity: str,
        topic: str,
        store: str,
        **kwargs: Any,
    ) -> MemoryIndexResult:
        """Index an assertion into a memory store."""
        params: dict[str, Any] = {
            "entity": entity,
            "topic": topic,
            "store": store,
            **kwargs,
        }
        raw = await self._runtime._rpc("mobkit/memory/index", params)
        return MemoryIndexResult.from_dict(raw)

    # -----------------------------------------------------------------
    # Cross-mob operations
    # -----------------------------------------------------------------

    async def wire_cross_mob(
        self,
        local_member_id: str,
        remote_member_id: str,
        remote_handle: "MobHandle",
    ) -> None:
        """Wire a local member to a member on another mob handle.

        Uses peer_info + wire_local on both sides.  Both mob handles must
        live in the **same process** — ``peer_info()`` always returns an
        ``inproc://`` address, which is only reachable within the same
        process.  For cross-process peering, call ``wire_local()`` on each
        handle directly, supplying the remote gateway's routable TCP or UDS
        address in place of the inproc address from ``peer_info()``.

        Args:
            local_member_id: Member on this mob to wire.
            remote_member_id: Member on the remote mob to wire.
            remote_handle: MobHandle for the remote mob (must be same-process).
        """
        local_info = await self.peer_info(local_member_id)
        remote_info = await remote_handle.peer_info(remote_member_id)
        await self.wire_local(
            local_member_id,
            remote_info["comms_name"],
            remote_info["peer_id"],
            remote_info["address"],
        )
        try:
            await remote_handle.wire_local(
                remote_member_id,
                local_info["comms_name"],
                local_info["peer_id"],
                local_info["address"],
            )
        except Exception:
            # Best-effort rollback: undo the local wire using the same
            # low-level API — no contact directory or peer handles needed.
            try:
                await self.unwire_local(
                    local_member_id,
                    remote_info["comms_name"],
                    remote_info["peer_id"],
                    remote_info["address"],
                )
            except Exception:
                pass
            raise

    async def send_cross_mob(
        self,
        remote_member_id: str,
        remote_handle: "MobHandle",
        message: str | None = None,
        *,
        content: list[dict[str, Any]] | None = None,
    ) -> SendMessageResult:
        """Send a message to a member on another mob handle.

        This is an app-level injection — the remote agent receives the
        message but does not know the sender. For agent-to-agent
        communication with sender identity and reply path, use
        ``wire_cross_mob()`` to set up peering, then agents communicate
        directly via their comms ``send`` tool.

        Args:
            remote_member_id: Target member on the remote mob.
            remote_handle: MobHandle for the remote mob.
            message: Plain text message.
            content: Multimodal content blocks.
        """
        return await remote_handle.send(remote_member_id, message, content=content)

    async def list_external_mobs(self) -> list:
        """List known external mobs from the contact directory."""
        from .types import CrossMobContactEntry

        raw = await self._runtime._rpc("mobkit/cross_mob/directory")
        mobs = raw.get("mobs", []) if isinstance(raw, dict) else []
        return [CrossMobContactEntry.from_dict(m) for m in mobs]

    async def peer_info(self, member_id: str) -> dict[str, str]:
        """Get comms peer info for a local member.

        Returns ``{"member_id", "mob_id", "comms_name", "peer_id", "address"}``.
        Used to build peer specs for cross-mob wiring via ``wire_local``.
        """
        raw = await self._runtime._rpc(
            "mobkit/cross_mob/peer_info",
            {"member_id": member_id},
        )
        return raw if isinstance(raw, dict) else {}

    async def wire_local(
        self,
        local_member_id: str,
        remote_comms_name: str,
        remote_peer_id: str,
        remote_address: str,
    ) -> None:
        """Wire a local member to a remote peer (local side only).

        For same-process (inproc) cross-mob wiring::

            # Get peer info from each side
            a_info = await core.peer_info("school")
            b_info = await gw.peer_info("calendar")

            # Wire each side to the other (inproc address from peer_info)
            await core.wire_local("school", b_info["comms_name"], b_info["peer_id"], b_info["address"])
            await gw.wire_local("calendar", a_info["comms_name"], a_info["peer_id"], a_info["address"])

        For cross-process (TCP/UDS), replace the address with the remote
        gateway's transport endpoint — peer_info always returns inproc.
        """
        await self._runtime._rpc(
            "mobkit/cross_mob/wire_local",
            {
                "local_member_id": local_member_id,
                "remote_comms_name": remote_comms_name,
                "remote_peer_id": remote_peer_id,
                "remote_address": remote_address,
            },
        )

    async def unwire_local(
        self,
        local_member_id: str,
        remote_comms_name: str,
        remote_peer_id: str,
        remote_address: str,
    ) -> None:
        """Undo a wire_local — unwire a local member from a previously wired peer (local side only)."""
        await self._runtime._rpc(
            "mobkit/cross_mob/unwire_local",
            {
                "local_member_id": local_member_id,
                "remote_comms_name": remote_comms_name,
                "remote_peer_id": remote_peer_id,
                "remote_address": remote_address,
            },
        )

    # -----------------------------------------------------------------
    # Rich member inspection
    # -----------------------------------------------------------------

    async def member_status(self, member_id: str) -> RichMemberSnapshot:
        """Return rich execution status for a member."""
        from .types import RichMemberSnapshot
        raw = await self._runtime._rpc("mobkit/member_status", {"member_id": member_id})
        return RichMemberSnapshot.from_dict(raw)

    async def force_cancel_member(self, member_id: str) -> None:
        """Force-cancel a running member immediately."""
        await self._runtime._rpc("mobkit/force_cancel_member", {"member_id": member_id})

    # -----------------------------------------------------------------
    # Helper convenience
    # -----------------------------------------------------------------

    async def spawn_helper(
        self,
        meerkat_id: str,
        task: str,
        *,
        profile: str | None = None,
        runtime_mode: str | None = None,
        backend: str | None = None,
    ) -> HelperResult:
        """Spawn a short-lived helper member and return its result."""
        from .types import HelperResult
        params: dict[str, Any] = {"meerkat_id": meerkat_id, "task": task}
        options: dict[str, Any] = {}
        if profile is not None:
            options["profile"] = profile
        if runtime_mode is not None:
            options["runtime_mode"] = runtime_mode
        if backend is not None:
            options["backend"] = backend
        if options:
            params["options"] = options
        raw = await self._runtime._rpc("mobkit/spawn_helper", params)
        return HelperResult.from_dict(raw)

    async def fork_helper(
        self,
        source_member_id: str,
        meerkat_id: str,
        task: str,
        *,
        fork_context: dict | None = None,
        profile: str | None = None,
        runtime_mode: str | None = None,
        backend: str | None = None,
    ) -> HelperResult:
        """Fork a helper from an existing member's context."""
        from .types import HelperResult
        params: dict[str, Any] = {
            "source_member_id": source_member_id,
            "meerkat_id": meerkat_id,
            "task": task,
        }
        if fork_context is not None:
            params["fork_context"] = fork_context
        options: dict[str, Any] = {}
        if profile is not None:
            options["profile"] = profile
        if runtime_mode is not None:
            options["runtime_mode"] = runtime_mode
        if backend is not None:
            options["backend"] = backend
        if options:
            params["options"] = options
        raw = await self._runtime._rpc("mobkit/fork_helper", params)
        return HelperResult.from_dict(raw)

    # -----------------------------------------------------------------
    # Session attachment
    # -----------------------------------------------------------------

    async def attach_session(
        self,
        profile: str,
        meerkat_id: str,
        session_id: str,
    ) -> RichMemberSnapshot:
        """Attach a member to an existing session (resume mode)."""
        from .types import RichMemberSnapshot
        params: dict[str, Any] = {
            "profile": profile,
            "meerkat_id": meerkat_id,
            "session_id": session_id,
        }
        raw = await self._runtime._rpc("mobkit/attach_existing_session", params)
        return RichMemberSnapshot.from_dict(raw)

    # -----------------------------------------------------------------
    # Flow lifecycle
    # -----------------------------------------------------------------

    async def cancel_flow(self, run_id: str) -> None:
        """Cancel a running flow by run ID."""
        await self._runtime._rpc("mobkit/cancel_flow", {"run_id": run_id})

    async def flow_status(self, run_id: str) -> MobRunSnapshot | None:
        """Get flow run status. Returns None if run not found."""
        from .types import MobRunSnapshot
        raw = await self._runtime._rpc("mobkit/flow_status", {"run_id": run_id})
        if raw is None:
            return None
        if isinstance(raw, dict) and raw.get("status") == "not_found":
            return None
        return MobRunSnapshot.from_dict(raw)

    # -----------------------------------------------------------------
    # Batch
    # -----------------------------------------------------------------

    async def collect_completed(self) -> list[tuple[str, RichMemberSnapshot]]:
        """Collect all members that have reached a final state."""
        from .types import RichMemberSnapshot
        raw = await self._runtime._rpc("mobkit/collect_completed")
        results: list[tuple[str, RichMemberSnapshot]] = []
        entries = raw.get("completed", []) if isinstance(raw, dict) else raw if isinstance(raw, list) else []
        for entry in entries:
            member_id = entry.get("member_id", "")
            snapshot = RichMemberSnapshot.from_dict(entry.get("snapshot", entry))
            results.append((member_id, snapshot))
        return results

    # -----------------------------------------------------------------
    # Session introspection
    # -----------------------------------------------------------------

    async def member_session_id(self, member_id: str) -> str | None:
        """Return the current session ID for a member, or None."""
        raw = await self._runtime._rpc("mobkit/member_current_session_id", {"member_id": member_id})
        if isinstance(raw, dict):
            return raw.get("session_id")
        return None

    async def member_session_ref(self, member_id: str) -> MemberSessionRef | None:
        """Return the session reference for a member, or None."""
        from .types import MemberSessionRef
        raw = await self._runtime._rpc("mobkit/member_session_ref", {"member_id": member_id})
        if raw is None:
            return None
        if isinstance(raw, dict) and raw.get("session_id") is None:
            return None
        return MemberSessionRef.from_dict(raw)

    # -----------------------------------------------------------------
    # Polling helpers (client-side)
    # -----------------------------------------------------------------

    async def wait_one(
        self,
        member_id: str,
        *,
        poll_interval: float = 1.0,
        timeout: float | None = None,
    ) -> RichMemberSnapshot:
        """Poll member_status until the member reaches a final state.

        Raises ``TimeoutError`` if *timeout* seconds elapse before completion.
        """
        import asyncio
        import time

        from .types import RichMemberSnapshot

        deadline = time.monotonic() + timeout if timeout is not None else None
        while True:
            snapshot = await self.member_status(member_id)
            if snapshot.is_final:
                return snapshot
            if deadline is not None and time.monotonic() >= deadline:
                raise TimeoutError(
                    f"member {member_id!r} did not reach final state "
                    f"within {timeout}s"
                )
            await asyncio.sleep(poll_interval)

    async def wait_all(
        self,
        member_ids: list[str],
        *,
        poll_interval: float = 1.0,
        timeout: float | None = None,
    ) -> list[RichMemberSnapshot]:
        """Wait for all listed members to reach final state.

        Polls in parallel via ``asyncio.gather``. Raises ``TimeoutError``
        if *timeout* seconds elapse before all members complete.
        """
        import asyncio

        from .types import RichMemberSnapshot

        tasks = [
            self.wait_one(mid, poll_interval=poll_interval, timeout=timeout)
            for mid in member_ids
        ]
        return list(await asyncio.gather(*tasks))

    # Alias for backward compatibility
    send_message = send

    async def subscribe_agent(self, member_id: str) -> AsyncIterator[AgentEvent]:
        """Stream events for one agent. Pure observation."""
        bridge = self._runtime.sse_bridge()
        async for event in bridge.agent_events(member_id):
            yield AgentEvent.from_sse(event, agent_id=member_id)

    async def subscribe_mob(self) -> AsyncIterator[MobEvent]:
        """Stream mob-wide events. Pure observation."""
        bridge = self._runtime.sse_bridge()
        async for event in bridge.mob_events():
            yield MobEvent.from_sse(event)


class ToolCaller:
    """Bound callable scoped to one MCP module.

    Wraps ``MobHandle.call_tool`` with a fixed ``module_id`` and unwraps
    the result so callers get raw data instead of ``CallToolResult``.
    """

    def __init__(self, mob_handle: MobHandle, module_id: str) -> None:
        self._mob_handle = mob_handle
        self._module_id = module_id

    async def __call__(self, tool: str, **kwargs: Any) -> Any:
        """Call a tool on the bound MCP module, return unwrapped result.

        Raises whatever ``call_tool`` raises on failure (e.g. ``RpcError``).
        """
        result = await self._mob_handle.call_tool(self._module_id, tool, kwargs or None)
        return result.result


class SseBridge:
    """Bridge for streaming SSE from the Rust backend's HTTP server."""

    def __init__(self, runtime: MobKitRuntime):
        self._runtime = runtime

    def _base_url(self) -> str:
        base = self._runtime.rust_http_base_url
        if base is None:
            raise NotConnectedError(
                "SSE bridge requires rust_http_base_url — set it via "
                "runtime.set_rust_http_base('http://127.0.0.1:8081') or "
                "ensure the Rust binary reports it during bootstrap"
            )
        return base

    async def agent_events(self, agent_id: str) -> AsyncIterator[SseEvent]:
        url = f"{self._base_url()}/agents/{agent_id}/events"
        async for event in self._stream_sse(url):
            yield event

    async def mob_events(self) -> AsyncIterator[SseEvent]:
        url = f"{self._base_url()}/mob/events"
        async for event in self._stream_sse(url):
            yield event

    async def _stream_sse(
        self,
        url: str,
        *,
        method: str = "GET",
        body: bytes | None = None,
    ) -> AsyncIterator[SseEvent]:
        async def _read_chunks() -> AsyncIterator[bytes]:
            req = urllib_request.Request(url, method=method, data=body)
            req.add_header("Accept", "text/event-stream")
            if body is not None:
                req.add_header("Content-Type", "application/json")
            response = await asyncio.to_thread(urllib_request.urlopen, req)
            try:
                while True:
                    chunk = await asyncio.to_thread(response.read, 4096)
                    if not chunk:
                        break
                    yield chunk
            finally:
                response.close()

        async for event in parse_sse_stream(_read_chunks()):
            yield event


class AsgiApp:
    """ASGI app: REST handled directly, SSE proxied to Rust backend."""

    def __init__(
        self,
        runtime: MobKitRuntime,
        console: bool = True,
        auth_config: Any | None = None,
        fallback_app: Any | None = None,
    ):
        if auth_config is not None:
            config_dict = _auth_config_to_dict(auth_config)
            if config_dict.get("provider") == "google":
                raise ValueError(
                    "GoogleAuthConfig cannot be used with the Python ASGI facade — "
                    "asymmetric OIDC/JWKS verification requires the Rust gateway. "
                    "Use auth=auth.jwt(...) for direct ASGI deployments, or route "
                    "through the Rust gateway for Google auth."
                )
        self._runtime = runtime
        self._console = console
        self._auth_config = auth_config
        self._fallback_app = self._normalize_fallback(fallback_app)

    @staticmethod
    def _normalize_fallback(app: Any) -> Any:
        if app is None:
            return None
        if callable(app):
            return app
        if isinstance(app, list):
            try:
                from starlette.applications import Starlette
            except ImportError:
                raise ImportError(
                    "extra_routes is a list of Route objects but starlette is not installed. "
                    "Install starlette or pass an ASGI app directly."
                )
            return Starlette(routes=app)
        return app

    async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
        if scope["type"] != "http":
            if self._fallback_app is not None:
                await self._fallback_app(scope, receive, send)
            return

        path: str = scope.get("path", "/")
        method: str = scope.get("method", "GET")

        if path == "/healthz":
            await _send_response(send, 200, b"ok", content_type=b"text/plain")
            return

        # Gate console/observation routes when console=False.
        if not self._console:
            is_console_route = (
                path.startswith("/console")
                or path == "/mob/events"
                or (path.startswith("/agents/") and path.endswith("/events"))
            )
            if is_console_route:
                await _send_response(send, 404, b'{"error":"not found"}')
                return

        # Enforce auth when auth_config is provided.
        if self._auth_config is not None and path != "/healthz":
            headers = dict(scope.get("headers", []))
            auth_header = headers.get(
                b"authorization", b""
            ).decode("utf-8", errors="replace")
            if not auth_header.startswith("Bearer ") or not auth_header[7:].strip():
                resp = json.dumps({"error": "unauthorized"}).encode()
                await _send_response(send, 401, resp)
                return
            token = auth_header[7:].strip()
            if not _validate_bearer_token(token, self._auth_config):
                resp = json.dumps({"error": "unauthorized", "reason": "invalid_token"}).encode()
                await _send_response(send, 401, resp)
                return

        if path == "/rpc" and method == "POST":
            body = await _read_body(receive)
            request_id = None
            try:
                parsed = json.loads(body)
            except (json.JSONDecodeError, ValueError) as exc:
                resp = json.dumps({
                    "jsonrpc": "2.0",
                    "id": None,
                    "error": {"code": -32700, "message": f"Parse error: {exc}"},
                }).encode()
                await _send_response(send, 200, resp)
                return
            if not isinstance(parsed, dict) or "method" not in parsed:
                resp = json.dumps({
                    "jsonrpc": "2.0",
                    "id": parsed.get("id") if isinstance(parsed, dict) else None,
                    "error": {"code": -32600, "message": "Invalid Request: must be a JSON object with a method field"},
                }).encode()
                await _send_response(send, 200, resp)
                return
            request_id = parsed.get("id")
            try:
                result = await self._runtime._rpc(
                    parsed.get("method", ""),
                    parsed.get("params"),
                )
                resp = json.dumps({
                    "jsonrpc": "2.0",
                    "id": request_id,
                    "result": result,
                }).encode()
                await _send_response(send, 200, resp)
            except RpcError as exc:
                resp = json.dumps({
                    "jsonrpc": "2.0",
                    "id": request_id,
                    "error": {"code": exc.code, "message": exc.message},
                }).encode()
                await _send_response(send, 200, resp)
            except Exception as exc:
                resp = json.dumps({
                    "jsonrpc": "2.0",
                    "id": request_id,
                    "error": {"code": -32603, "message": str(exc)},
                }).encode()
                await _send_response(send, 200, resp)
            return

        if path.startswith("/agents/") and path.endswith("/events") and method == "GET":
            parts = path.split("/")
            if len(parts) >= 4:
                agent_id = parts[2]
                bridge = self._runtime.sse_bridge()
                await self._proxy_sse(send, bridge.agent_events(agent_id))
                return

        if path == "/mob/events" and method == "GET":
            bridge = self._runtime.sse_bridge()
            await self._proxy_sse(send, bridge.mob_events())
            return

        if self._fallback_app is not None:
            await self._fallback_app(scope, receive, send)
            return

        await _send_response(send, 404, b'{"error":"not found"}')

    async def _proxy_sse(
        self,
        send: Any,
        event_stream: AsyncIterator[SseEvent],
    ) -> None:
        try:
            first_event: SseEvent | None = None
            async for event in event_stream:
                first_event = event
                break

            if first_event is None:
                await _send_response(send, 204, b"")
                return
        except Exception as exc:
            err = json.dumps({"error": f"SSE backend unavailable: {exc}"}).encode()
            await _send_response(send, 502, err)
            return

        await send({
            "type": "http.response.start",
            "status": 200,
            "headers": [
                [b"content-type", b"text/event-stream"],
                [b"cache-control", b"no-cache"],
                [b"connection", b"keep-alive"],
            ],
        })

        await send({
            "type": "http.response.body",
            "body": first_event.encode().encode("utf-8"),
            "more_body": True,
        })

        try:
            async for event in event_stream:
                chunk = event.encode().encode("utf-8")
                await send({"type": "http.response.body", "body": chunk, "more_body": True})
        except Exception:
            pass
        await send({"type": "http.response.body", "body": b""})


def _serialize_config(config: Any, _seen: set[int] | None = None) -> Any:
    """Serialize a config object to a JSON-compatible dict.

    Calls to_dict() on dataclass configs (GoogleAuthConfig, JwtAuthConfig,
    etc.) so json.dumps won't fail with TypeError during mobkit/init.
    Non-serializable leaves (e.g. storage backend instances) are converted
    to their qualified class name so the gateway receives a meaningful
    string instead of crashing the transport.  Cycle-safe via id-set.
    """
    if config is None or isinstance(config, (bool, int, float, str)):
        return config
    obj_id = id(config)
    if _seen is None:
        _seen = set()
    if obj_id in _seen:
        return f"[circular:{type(config).__qualname__}]"
    _seen.add(obj_id)
    if hasattr(config, "to_dict"):
        return config.to_dict()
    if isinstance(config, dict):
        return {k: _serialize_config(v, _seen) for k, v in config.items()}
    if isinstance(config, (list, tuple)):
        return [_serialize_config(v, _seen) for v in config]
    # Non-serializable object — use qualified class name so the gateway
    # gets a meaningful identifier instead of a TypeError.
    return f"{type(config).__module__}.{type(config).__qualname__}"


def _auth_config_to_dict(auth_config: Any) -> dict[str, Any]:
    """Normalize an auth config to a plain dict.

    Accepts JwtAuthConfig/GoogleAuthConfig (with to_dict()), plain dicts,
    or anything else (returns empty dict for fail-closed behavior).
    """
    if hasattr(auth_config, "to_dict"):
        return auth_config.to_dict()
    if isinstance(auth_config, dict):
        return auth_config
    return {}


def _validate_bearer_token(token: str, auth_config: Any) -> bool:
    """Validate a bearer token against the auth config.

    For JwtAuthConfig / {"provider": "jwt"} (shared-secret HMAC): full
    signature verification plus iss, aud, exp, and nbf claim checks
    with leeway.

    Google and unknown providers are rejected (fail closed). Google auth
    is blocked at AsgiApp construction time, but this is a defense-in-depth
    fallback.
    """
    config_dict = _auth_config_to_dict(auth_config)
    provider = config_dict.get("provider", "")

    if provider != "jwt":
        return False

    parts = token.split(".")
    if len(parts) != 3:
        return False

    try:
        header_b64 = parts[0] + "=" * (-len(parts[0]) % 4)
        header = json.loads(base64.urlsafe_b64decode(header_b64))
    except Exception:
        return False

    # Header must be a dict with alg field.
    if not isinstance(header, dict):
        return False

    secret = config_dict.get("shared_secret", "")
    if not secret:
        return False
    if header.get("alg") != "HS256":
        return False

    # Verify HMAC-SHA256 signature
    signing_input = f"{parts[0]}.{parts[1]}".encode("utf-8")
    expected_sig = base64.urlsafe_b64encode(
        hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()
    ).rstrip(b"=").decode("utf-8")
    if not hmac.compare_digest(expected_sig, parts[2]):
        return False

    # Decode and validate claims — must be a JSON object.
    try:
        payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
        claims = json.loads(base64.urlsafe_b64decode(payload_b64))
    except Exception:
        return False

    if not isinstance(claims, dict):
        return False

    if config_dict.get("issuer") and claims.get("iss") != config_dict["issuer"]:
        return False
    expected_aud = config_dict.get("audience")
    if expected_aud:
        token_aud = claims.get("aud")
        # JWT aud may be a string or an array of strings (RFC 7519 §4.1.3).
        if isinstance(token_aud, list):
            if expected_aud not in token_aud:
                return False
        elif token_aud != expected_aud:
            return False

    # Enforce expiry with leeway — exp/nbf must be numeric.
    leeway = config_dict.get("leeway_seconds", 60)
    now = time.time()
    exp = claims.get("exp")
    if exp is not None:
        if not isinstance(exp, (int, float)):
            return False
        if now > exp + leeway:
            return False
    nbf = claims.get("nbf")
    if nbf is not None:
        if not isinstance(nbf, (int, float)):
            return False
        if now < nbf - leeway:
            return False

    return True


async def _read_body(receive: Any) -> bytes:
    body = b""
    while True:
        message = await receive()
        body += message.get("body", b"")
        if not message.get("more_body", False):
            break
    return body


async def _send_response(
    send: Any,
    status: int,
    body: bytes,
    content_type: bytes = b"application/json",
) -> None:
    await send({
        "type": "http.response.start",
        "status": status,
        "headers": [[b"content-type", content_type]],
    })
    await send({"type": "http.response.body", "body": body})
