"""ServiceBridge Python SDK main module.

Quick start::

    import asyncio
    from service_bridge import ServiceBridge

    svc = ServiceBridge("localhost:14445", "my-key", "my-service")

    @svc.handle_rpc("my.fn")
    async def my_handler(payload: dict) -> dict:
        return {"ok": True}

    @svc.handle_event("my.topic", group_name="my.group")
    async def my_event(payload: dict, ec) -> None:
        pass

    asyncio.run(svc.serve())
"""

from __future__ import annotations

import asyncio
import json
import logging
import random
import threading
import time
from collections import deque
from concurrent import futures
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Callable, Optional

import grpc

from service_bridge.pb import servicebridge_pb2 as pb
from service_bridge.pb import servicebridge_pb2_grpc as pb_grpc
from service_bridge.http_catalog import (
    HttpSpan,
    extract_trace_from_headers,
    register_http_endpoint,
    _new_hex_id,
)

logger = logging.getLogger(__name__)
_sdk_logger = logging.getLogger(__name__ + ".internal")


# ─── Error types ──────────────────────────────────────────────────────────────

class ServiceBridgeError(Exception):
    """Typed error raised by all ServiceBridge SDK operations.

    Attributes:
        component: SDK subsystem that produced the error (e.g. "event", "job").
        operation: Specific operation that failed.
        severity: One of "fatal", "retriable", "ignorable".
        retryable: Whether the caller should retry.
        code: gRPC status code string (if applicable).
    """

    def __init__(
        self,
        message: str,
        *,
        component: str = "",
        operation: str = "",
        severity: str = "retriable",
        retryable: bool = True,
        code: Optional[str] = None,
    ) -> None:
        super().__init__(message)
        self.component = component
        self.operation = operation
        self.severity = severity
        self.retryable = retryable
        self.code = code

    def __repr__(self) -> str:
        return (
            f"ServiceBridgeError({self!s}, component={self.component!r}, "
            f"operation={self.operation!r}, severity={self.severity!r})"
        )


# ─── Public types ──────────────────────────────────────────────────────────────

class StreamWriter:
    """Allows a handler to push incremental data chunks to ServiceBridge
    during execution (AppendStream). Writes are silently dropped when offline.
    """

    def __init__(self, run_id: str, client: "ServiceBridge") -> None:
        self._run_id = run_id
        self._client = client

    async def write(self, data: Any, key: str = "default") -> None:
        """Push a data chunk to the named stream lane.

        Args:
            data: JSON-serialisable payload.
            key: Stream lane name (default: "default").
        """
        if not self._run_id or not self._client._stub:
            return
        with self._client._queue_lock:
            online = self._client._is_online
        if not online:
            return  # stream writes are not buffered offline
        payload = json.dumps(data).encode()
        try:
            await asyncio.get_event_loop().run_in_executor(
                None,
                lambda: self._client._stub.AppendStream(
                    pb.AppendStreamRequest(run_id=self._run_id, key=key, data=payload),
                    metadata=self._client._metadata(),
                    timeout=5,
                ),
            )
        except Exception:
            pass  # best-effort


@dataclass
class EventContext:
    """Delivery metadata and control methods passed to event handlers."""

    trace_id: str
    span_id: str
    topic: str
    group_name: str
    message_id: str
    attempt: int
    headers: dict[str, str]
    stream: StreamWriter = field(repr=False, default=None)  # type: ignore[assignment]
    _retry_fn: Callable[[int], None] = field(repr=False, default=None)  # type: ignore[assignment]
    _reject_fn: Callable[[str], None] = field(repr=False, default=None)  # type: ignore[assignment]

    def retry(self, delay_ms: int = 1000) -> None:
        """Request a retry after delay_ms milliseconds."""
        if self._retry_fn:
            self._retry_fn(delay_ms)

    def reject(self, reason: str = "rejected_by_consumer") -> None:
        """Permanently reject this message (moves to DLQ)."""
        if self._reject_fn:
            self._reject_fn(reason)


@dataclass
class ScheduleOpts:
    """Configuration for a scheduled job."""

    cron: str = ""
    """Cron expression (e.g. "0 * * * *"). Mutually exclusive with delay_ms."""
    delay_ms: int = 0
    """One-shot delay in milliseconds."""
    timezone: str = "UTC"
    """IANA timezone name."""
    misfire: str = "fire_now"
    """Misfire policy: "fire_now" (default) or "skip"."""
    via: str = "rpc"
    """Trigger mechanism: "rpc" (default), "event", or "workflow"."""
    retry_policy_json: str = ""
    """JSON-encoded retry policy."""


@dataclass
class WorkflowStep:
    """One step in a workflow DAG."""

    id: str
    type: str
    """One of "rpc", "event", "event_wait", "sleep", "workflow"."""
    ref: str = ""
    """Function, event topic, or sub-workflow name."""
    deps: list[str] = field(default_factory=list)
    """Step IDs that must complete before this step runs."""
    if_expr: str = ""
    """Optional server-side filter expression."""
    timeout_ms: int = 0
    timeout_ms_field: int = field(init=False, repr=False, default=0)
    duration_ms: int = 0
    """Used only for type="sleep"."""


@dataclass
class WatchRunOpts:
    """Options for watch_run()."""

    key: str = ""
    """Filter to a specific stream lane. Empty = all keys."""
    from_sequence: int = 0
    """Replay chunks with sequence > from_sequence (0 = full replay)."""


@dataclass
class RunStreamEvent:
    """One chunk or terminal event from watch_run()."""

    sequence: int
    key: str
    data: Any
    done: bool
    run_status: str = ""


# ─── Schema types ─────────────────────────────────────────────────────────────

@dataclass
class RpcFieldDef:
    """Describes one field in an inline Protobuf schema."""

    type: str
    """Protobuf scalar type: "string", "int32", "int64", "float", "double",
    "bool", "bytes", "uint32", "uint64", "fixed32", "fixed64", "sint32",
    "sint64"."""
    id: int
    """Protobuf field number (must be unique within the message)."""
    repeated: bool = False
    """True for repeated (array) fields."""


# RpcSchema maps field names to their RpcFieldDef.
RpcSchema = dict[str, RpcFieldDef]


@dataclass
class RpcSchemaOpts:
    """Inline Protobuf schemas for an RPC function's input and output."""

    input: Optional[RpcSchema] = None
    output: Optional[RpcSchema] = None


@dataclass
class Options:
    """SDK configuration options."""

    admin_url: str = ""
    heartbeat_interval_ms: int = 10_000
    skip_tls: bool = False
    capture_logs: bool = True
    """When True (default), attaches a ServiceBridgeLogHandler to the root logger."""
    queue_max_size: int = 1_000
    """Maximum operations buffered in the offline queue (default: 1000)."""
    queue_overflow: str = "drop-oldest"
    """Overflow policy: "drop-oldest" (default), "drop-newest", or "error"."""
    discovery_refresh_ms: int = 10_000
    """How often per-function endpoint lists are refreshed (ms, default: 10000)."""


# Context variable that propagates trace context through async code.
import contextvars as _cv
_trace_ctx: _cv.ContextVar[dict[str, str]] = _cv.ContextVar("_sb_trace_ctx", default={})


# ─── Helper: is gRPC error a connection error? ────────────────────────────────

def _is_connection_error(exc: Exception) -> bool:
    """Return True for gRPC UNAVAILABLE / UNKNOWN status codes."""
    if isinstance(exc, grpc.RpcError):
        code = exc.code()  # type: ignore[attr-defined]
        return code in (grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.UNKNOWN)
    return False


# ─── ServiceBridge client ──────────────────────────────────────────────────────

class ServiceBridge:
    """ServiceBridge SDK client.

    Args:
        grpc_url: Address of the ServiceBridge gRPC server (e.g. ``"localhost:14445"``).
        service_key: Service authentication key.
        service_name: Logical name for this service.
        opts: Optional :class:`Options`.
    """

    def __init__(
        self,
        grpc_url: str,
        service_key: str,
        service_name: str,
        opts: Optional[Options] = None,
    ) -> None:
        self._grpc_url = grpc_url
        self._service_key = service_key
        self._service_name = service_name
        self._opts = opts or Options()
        self._instance_id = _new_hex_id(3)

        self._rpc_handlers: dict[str, Any] = {}
        self._rpc_opts: dict[str, dict[str, Any]] = {}  # fn -> {allowed_callers, schema}
        self._event_handlers: dict[str, dict[str, Any]] = {}

        self._channel: Optional[grpc.Channel] = None
        self._stub: Optional[pb_grpc.ServiceBridgeStub] = None
        self._worker_server: Optional[grpc.Server] = None
        self._worker_address: str = ""

        # Per-function gRPC channel cache (persistent, round-robin via grpc).
        self._fn_channels: dict[str, tuple[grpc.Channel, list[str]]] = {}
        self._fn_channels_lock = threading.Lock()

        # Schema registry (fn -> RpcSchemaOpts).
        self._schemas: dict[str, RpcSchemaOpts] = {}
        self._compiled_schemas: dict[str, Any] = {}  # fn -> compiled cache

        # Offline queue.
        self._is_online: bool = False
        self._queue: deque[dict[str, Any]] = deque()
        self._queue_lock = threading.Lock()
        self._online_restore_timer: Optional[threading.Timer] = None

        # Heartbeat / lifecycle.
        self._heartbeat_thread: Optional[threading.Thread] = None
        self._stop_event = threading.Event()
        self._stopped = False

        # Log batching.
        self._log_batch: list[pb.LogEntry] = []
        self._log_lock = threading.Lock()
        self._log_timer: Optional[threading.Timer] = None

        if self._opts.capture_logs:
            self.attach_logging_handler()

    # ─── Schema registration ───────────────────────────────────────────────────

    def register_schema(self, fn: str, opts: RpcSchemaOpts) -> None:
        """Compile and cache Protobuf schemas for the named function.

        Call before ``serve()`` to enable binary Protobuf encoding.
        The schema JSON is also published to the control plane via RegisterFunction.
        """
        self._schemas[fn] = opts
        # Lazy compilation happens on first encode/decode.

    # ─── Registration API ──────────────────────────────────────────────────────

    def handle_rpc(
        self,
        fn: str,
        *,
        allowed_callers: Optional[list[str]] = None,
        schema: Optional[RpcSchemaOpts] = None,
    ) -> Callable:
        """Decorator that registers an RPC handler.

        Usage::

            @svc.handle_rpc("order.create")
            async def create_order(payload: dict) -> dict:
                return {"id": "123"}

            # With full context (stream + trace):
            @svc.handle_rpc("order.create", allowed_callers=["payments"])
            async def create_order(payload: dict, ctx) -> dict:
                await ctx.stream.write({"progress": 50})
                return {"id": "123"}
        """
        def decorator(func: Callable) -> Callable:
            self._rpc_handlers[fn] = func
            self._rpc_opts[fn] = {
                "allowed_callers": allowed_callers or [],
                "schema": schema,
            }
            if schema:
                self._schemas[fn] = schema
            return func
        return decorator

    def handle_event(
        self,
        topic: str,
        group_name: str = "",
        retry_policy_json: str = "",
        filter_expr: str = "",
    ) -> Callable:
        """Decorator that registers an event handler.

        Raises ``ValueError`` if the same group_name is registered twice.

        Usage::

            @svc.handle_event("order.created", group_name="payments.order.created")
            async def on_order_created(payload: dict, ec: EventContext) -> None:
                pass
        """
        gname = group_name or f"{self._service_name}.{topic}"

        def decorator(func: Callable) -> Callable:
            if gname in self._event_handlers:
                logger.warning(
                    "handle_event: duplicate group_name %r — overwriting existing handler", gname
                )
            self._event_handlers[gname] = {
                "handler": func,
                "group_name": gname,
                "topic": topic,
                "retry_policy_json": retry_policy_json,
                "filter_expr": filter_expr,
            }
            return func

        return decorator

    # ─── Serve ────────────────────────────────────────────────────────────────

    async def serve(self, *, host: str = "127.0.0.1", skip_tls: bool | None = None) -> None:
        """Start the worker server and block until cancelled.

        This is the main entrypoint — call it with ``asyncio.run(svc.serve())``.
        """
        _skip_tls = skip_tls if skip_tls is not None else self._opts.skip_tls

        # 1. Connect control plane
        await self._connect_control_plane(_skip_tls)

        # Mark online and flush any queued operations.
        with self._queue_lock:
            self._is_online = True
        threading.Thread(target=self._flush_queue, daemon=True).start()

        # 2. Start worker gRPC server
        self._worker_address = await self._start_worker_server(host)
        logger.info("[servicebridge] worker listening at %s", self._worker_address)

        # 3. Register + heartbeat
        await self._sync_registrations()
        self._start_heartbeat()

        # 4. Block until cancelled
        try:
            await asyncio.Event().wait()
        except (asyncio.CancelledError, KeyboardInterrupt):
            pass
        finally:
            await self.stop()

    async def stop(self) -> None:
        """Gracefully shut down the SDK: flush logs, close channels, stop servers."""
        with self._queue_lock:
            if self._stopped:
                return
            self._stopped = True
            self._is_online = False
            self._queue.clear()
            if self._online_restore_timer is not None:
                self._online_restore_timer.cancel()
                self._online_restore_timer = None

        self._stop_event.set()
        self._log_flush()

        if self._worker_server:
            self._worker_server.stop(grace=2)
        if self._channel:
            self._channel.close()

        # Close per-function channels.
        with self._fn_channels_lock:
            for ch, _ in self._fn_channels.values():
                ch.close()
            self._fn_channels.clear()

    # ─── Rpc ──────────────────────────────────────────────────────────────────

    async def rpc(
        self,
        fn: str,
        payload: Any = None,
        *,
        retries: int = 3,
        timeout_ms: int = 30_000,
        trace_id: str = "",
    ) -> Any:
        """Call an RPC function on a remote worker.

        Uses a persistent gRPC channel per function with round-trip endpoint
        caching and background refresh (``discovery_refresh_ms``).

        Args:
            fn: Function name (e.g. ``"orders/order.create"``).
            payload: JSON-serialisable payload.
            retries: Number of retry attempts (0 = no retry).
            timeout_ms: Per-call timeout in milliseconds.
            trace_id: Override trace ID.

        Returns:
            Parsed JSON response from the worker.
        """
        if self._stub is None:
            raise ServiceBridgeError(
                "ServiceBridge not started — call serve() first",
                component="rpc", operation=fn, severity="fatal", retryable=False,
            )

        effective_trace_id = trace_id or _new_hex_id()
        span_id = _new_hex_id()

        # Encode payload (binary Protobuf if schema present, else JSON).
        payload_bytes = self._encode_payload(fn, payload)

        go_report = asyncio.get_event_loop().run_in_executor(
            None,
            lambda: self._report_call_start(
                effective_trace_id, span_id, "", fn,
                int(time.time() * 1000), payload_bytes, 1,
            ),
        )
        asyncio.ensure_future(go_report)

        # Get (or lazily create) a persistent channel for this function.
        channel, worker_stub = await self._get_fn_channel(fn)

        started_at = int(time.time() * 1000)
        last_err: Optional[Exception] = None

        for attempt in range(retries + 1):
            try:
                result = await asyncio.get_event_loop().run_in_executor(
                    None,
                    lambda: worker_stub.Handle(
                        pb.HandleRequest(
                            fn=fn,
                            payload=payload_bytes,
                            trace_id=effective_trace_id,
                            span_id=span_id,
                        ),
                        timeout=timeout_ms / 1000,
                    ),
                )
                if not result.success:
                    raise ServiceBridgeError(
                        result.error or "handler returned failure",
                        component="rpc", operation=fn,
                    )
                # Decode output (binary Protobuf → JSON if schema present).
                out = self._decode_output(fn, result.output)
                return out
            except Exception as exc:
                last_err = exc
                if _is_connection_error(exc):
                    self._mark_offline()
                if attempt < retries:
                    await asyncio.sleep(0.3 * (attempt + 1))

        raise last_err  # type: ignore[misc]

    # ─── Event ────────────────────────────────────────────────────────────────

    async def event(
        self,
        topic: str,
        payload: Any = None,
        *,
        idempotency_key: str = "",
        trace_id: str = "",
        headers: Optional[dict[str, str]] = None,
    ) -> str:
        """Publish an event to a topic.

        When the control plane is offline, the operation is buffered in the
        offline queue and retried transparently on reconnect.

        Returns:
            Assigned message ID (empty string when buffered offline).
        """
        effective_trace_id = trace_id or _new_hex_id()
        payload_bytes = json.dumps(payload).encode() if payload is not None else b"{}"

        with self._queue_lock:
            online = self._is_online

        if not online:
            self._enqueue_offline({
                "kind": "event",
                "topic": topic,
                "payload": payload_bytes,
                "idempotency_key": idempotency_key,
                "trace_id": effective_trace_id,
                "headers": headers or {},
            })
            return ""

        try:
            resp = await asyncio.get_event_loop().run_in_executor(
                None,
                lambda: self._stub.Publish(
                    pb.PublishRequest(
                        topic=topic,
                        payload=payload_bytes,
                        trace_id=effective_trace_id,
                        producer_service=self._service_name,
                        idempotency_key=idempotency_key,
                        headers=headers or {},
                    ),
                    metadata=self._metadata(),
                    timeout=10,
                ),
            )
            return resp.message_id
        except Exception as exc:
            if _is_connection_error(exc):
                self._mark_offline()
                self._enqueue_offline({
                    "kind": "event",
                    "topic": topic,
                    "payload": payload_bytes,
                    "idempotency_key": idempotency_key,
                    "trace_id": effective_trace_id,
                    "headers": headers or {},
                })
                return ""
            raise ServiceBridgeError(
                f"publish event {topic!r}: {exc}",
                component="event", operation="publish",
            ) from exc

    # ─── Job ──────────────────────────────────────────────────────────────────

    async def job(self, target: str, opts: Optional[ScheduleOpts] = None) -> str:
        """Register a scheduled job with the control plane.

        When offline, the operation is buffered and retried on reconnect.

        Returns:
            Assigned job ID (empty string when buffered offline).
        """
        o = opts or ScheduleOpts()
        misfire = o.misfire or "fire_now"
        via = o.via or "rpc"
        tz = o.timezone or "UTC"

        with self._queue_lock:
            online = self._is_online

        if not online:
            self._enqueue_offline({"kind": "job", "target": target, "opts": o})
            return ""

        try:
            resp = await asyncio.get_event_loop().run_in_executor(
                None,
                lambda: self._stub.RegisterJob(
                    pb.RegisterJobRequest(
                        cron_expr=o.cron,
                        timezone=tz,
                        misfire_policy=misfire,
                        target_type=via,
                        target_ref=target,
                        delay_ms=o.delay_ms,
                        service_name=self._service_name,
                        retry_policy_json=o.retry_policy_json,
                    ),
                    metadata=self._metadata(),
                    timeout=10,
                ),
            )
            return resp.id
        except Exception as exc:
            if _is_connection_error(exc):
                self._mark_offline()
                self._enqueue_offline({"kind": "job", "target": target, "opts": o})
                return ""
            raise ServiceBridgeError(
                f"register job {target!r}: {exc}",
                component="job", operation="register",
            ) from exc

    # ─── Workflow ─────────────────────────────────────────────────────────────

    async def workflow(self, name: str, steps: list[WorkflowStep]) -> str:
        """Register a workflow DAG with the control plane.

        When offline, the operation is buffered and retried on reconnect.

        Returns:
            Assigned workflow run ID (empty string when buffered offline).
        """
        definition = json.dumps([_step_to_dict(s) for s in steps])

        with self._queue_lock:
            online = self._is_online

        if not online:
            self._enqueue_offline({"kind": "workflow", "name": name, "steps": steps})
            return ""

        try:
            resp = await asyncio.get_event_loop().run_in_executor(
                None,
                lambda: self._stub.RegisterWorkflow(
                    pb.RegisterWorkflowRequest(
                        name=name,
                        definition=definition,
                        service_name=self._service_name,
                    ),
                    metadata=self._metadata(),
                    timeout=10,
                ),
            )
            return resp.id
        except Exception as exc:
            if _is_connection_error(exc):
                self._mark_offline()
                self._enqueue_offline({"kind": "workflow", "name": name, "steps": steps})
                return ""
            raise ServiceBridgeError(
                f"register workflow {name!r}: {exc}",
                component="workflow", operation="register",
            ) from exc

    async def cancel_workflow_run(self, run_id: str) -> None:
        """Cancel an in-progress workflow run by ID."""
        import http.client
        import urllib.parse

        admin = self._admin_base()
        url = urllib.parse.urlparse(admin)
        host = url.hostname or "localhost"
        port = url.port or 14444

        conn = http.client.HTTPConnection(host, port, timeout=10)
        try:
            conn.request(
                "DELETE",
                f"/api/workflow-runs/{run_id}",
                headers={"x-service-key": self._service_key},
            )
            resp = conn.getresponse()
            if resp.status not in (200, 204):
                body = resp.read().decode(errors="replace").strip()
                raise ServiceBridgeError(
                    f"cancel workflow run {run_id!r}: HTTP {resp.status}: {body}",
                    component="workflow", operation="cancel",
                )
        finally:
            conn.close()

    # ─── WatchRun ─────────────────────────────────────────────────────────────

    async def watch_run(
        self,
        run_id: str,
        opts: Optional[WatchRunOpts] = None,
    ) -> AsyncIterator[RunStreamEvent]:
        """Subscribe to a realtime stream of chunks for a workflow/job run.

        Auto-reconnects with exponential backoff (0.5s → 5s) on transient errors.
        Deduplicates chunks by sequence number.

        Usage::

            async for event in svc.watch_run(run_id):
                print(event.data)
                if event.done:
                    break
        """
        key = opts.key if opts else ""
        from_seq = opts.from_sequence if opts else 0

        backoff = 0.5
        last_seq = from_seq
        loop = asyncio.get_event_loop()

        while True:
            if self._stub is None:
                await asyncio.sleep(backoff)
                backoff = min(backoff * 2, 5.0)
                continue

            try:
                request = pb.WatchRunRequest(
                    run_id=run_id, key=key, from_sequence=last_seq
                )
                stream_call = self._stub.WatchRun(request, metadata=self._metadata())
                backoff = 0.5

                async for chunk in _aiter_grpc_stream(stream_call, loop):
                    if chunk.sequence > 0 and chunk.sequence <= last_seq:
                        continue  # dedup
                    if chunk.sequence > last_seq:
                        last_seq = chunk.sequence

                    data: Any = None
                    if chunk.data:
                        try:
                            data = json.loads(chunk.data)
                        except json.JSONDecodeError:
                            data = chunk.data.decode(errors="replace")

                    event = RunStreamEvent(
                        sequence=chunk.sequence,
                        key=chunk.key,
                        data=data,
                        done=chunk.type == "run_complete",
                        run_status=chunk.run_status,
                    )
                    yield event
                    if event.done:
                        return

            except asyncio.CancelledError:
                return
            except Exception:
                await asyncio.sleep(backoff)
                backoff = min(backoff * 2, 5.0)

    # ─── HTTP helpers ──────────────────────────────────────────────────────────

    def start_http_span(
        self,
        method: str,
        path: str,
        trace_id: str = "",
        parent_span_id: str = "",
    ) -> HttpSpan:
        """Create a tracing span for an incoming HTTP request."""
        effective_trace_id = trace_id or _new_hex_id()
        span_id = _new_hex_id()
        fn = f"http:{method}:{path}"
        started_at = int(time.time() * 1000)
        input_buf = json.dumps({"method": method, "path": path}).encode()

        self._report_call_start(effective_trace_id, span_id, parent_span_id, fn, started_at, input_buf, 1)

        return HttpSpan(
            trace_id=effective_trace_id,
            span_id=span_id,
            _fn=fn,
            _started_at=started_at,
            _client=self,
            _input_buf=input_buf,
        )

    def register_http_endpoint(
        self,
        method: str,
        route: str,
        instance_id: str = "",
        endpoint: str = "",
        allowed_callers: Optional[list[str]] = None,
    ) -> None:
        """Register an HTTP route in the ServiceBridge catalog."""
        register_http_endpoint(
            admin_url=self._admin_base(),
            service_key=self._service_key,
            service_name=self._service_name,
            method=method,
            route=route,
            instance_id=instance_id,
            endpoint=endpoint,
            allowed_callers=allowed_callers,
        )

    # ─── Offline queue ────────────────────────────────────────────────────────

    def _enqueue_offline(self, op: dict[str, Any]) -> None:
        with self._queue_lock:
            max_size = self._opts.queue_max_size
            policy = self._opts.queue_overflow

            if len(self._queue) >= max_size:
                if policy == "error":
                    raise ServiceBridgeError(
                        "ServiceBridge offline queue is full",
                        component="queue", operation="enqueue",
                        severity="retriable", retryable=True,
                    )
                elif policy == "drop-newest":
                    return
                else:  # "drop-oldest"
                    self._queue.popleft()

            self._queue.append(op)

    def _mark_offline(self) -> None:
        with self._queue_lock:
            if not self._is_online or self._stopped:
                return
            self._is_online = False
        self._schedule_online_restore()

    def _schedule_online_restore(self) -> None:
        with self._queue_lock:
            if self._stopped or self._is_online or self._online_restore_timer is not None:
                return

        def _restore() -> None:
            with self._queue_lock:
                self._online_restore_timer = None
                if self._stopped:
                    return
                self._is_online = True
            threading.Thread(target=self._flush_queue, daemon=True).start()

        with self._queue_lock:
            t = threading.Timer(2.0, _restore)
            t.daemon = True
            self._online_restore_timer = t
        t.start()

    def _flush_queue(self) -> None:
        """Drain the offline queue FIFO when online. Runs in a background thread."""
        while True:
            with self._queue_lock:
                if not self._is_online or len(self._queue) == 0 or self._stopped:
                    return
                op = self._queue[0]

            try:
                self._flush_op(op)
            except Exception as exc:
                if _is_connection_error(exc):
                    self._mark_offline()
                    return
                _sdk_logger.debug("flush offline queue op %r: %s (skipping)", op.get("kind"), exc)

            with self._queue_lock:
                if self._queue and self._queue[0] is op:
                    self._queue.popleft()

    def _flush_op(self, op: dict[str, Any]) -> None:
        kind = op["kind"]
        md = self._metadata()

        if kind == "event":
            self._stub.Publish(
                pb.PublishRequest(
                    topic=op["topic"],
                    payload=op["payload"],
                    trace_id=op.get("trace_id", ""),
                    producer_service=self._service_name,
                    idempotency_key=op.get("idempotency_key", ""),
                    headers=op.get("headers", {}),
                ),
                metadata=md,
                timeout=10,
            )
        elif kind == "job":
            o: ScheduleOpts = op["opts"]
            self._stub.RegisterJob(
                pb.RegisterJobRequest(
                    cron_expr=o.cron,
                    timezone=o.timezone or "UTC",
                    misfire_policy=o.misfire or "fire_now",
                    target_type=o.via or "rpc",
                    target_ref=op["target"],
                    delay_ms=o.delay_ms,
                    service_name=self._service_name,
                    retry_policy_json=o.retry_policy_json,
                ),
                metadata=md,
                timeout=10,
            )
        elif kind == "workflow":
            steps: list[WorkflowStep] = op["steps"]
            definition = json.dumps([_step_to_dict(s) for s in steps])
            self._stub.RegisterWorkflow(
                pb.RegisterWorkflowRequest(
                    name=op["name"],
                    definition=definition,
                    service_name=self._service_name,
                ),
                metadata=md,
                timeout=10,
            )
        elif kind == "reportCallStart":
            self._stub.ReportCallStart(
                pb.ReportCallStartRequest(
                    trace_id=op["trace_id"],
                    span_id=op["span_id"],
                    parent_span_id=op.get("parent_span_id", ""),
                    fn=op["fn"],
                    service_name=self._service_name,
                    instance_id=self._instance_id,
                    started_at=op["started_at"],
                    input=op["input"],
                    attempt=op["attempt"],
                ),
                metadata=md,
                timeout=5,
            )
        elif kind == "reportCall":
            self._stub.ReportCall(
                pb.ReportCallRequest(
                    trace_id=op["trace_id"],
                    span_id=op["span_id"],
                    fn=op["fn"],
                    service_name=self._service_name,
                    instance_id=self._instance_id,
                    started_at=op["started_at"],
                    duration_ms=int(time.time() * 1000) - op["started_at"],
                    success=op["success"],
                    error=op.get("error", ""),
                    input=op["input"],
                    output=op.get("output", b""),
                    attempt=op["attempt"],
                ),
                metadata=md,
                timeout=5,
            )

    # ─── Per-function channel cache ────────────────────────────────────────────

    async def _get_fn_channel(
        self, fn: str
    ) -> tuple[grpc.Channel, "pb_grpc.ServiceBridgeWorkerStub"]:
        """Return (or lazily create) a persistent worker gRPC channel for ``fn``."""
        with self._fn_channels_lock:
            if fn in self._fn_channels:
                ch, _ = self._fn_channels[fn]
                return ch, pb_grpc.ServiceBridgeWorkerStub(ch)

        # Lookup endpoints for this function.
        loop = asyncio.get_event_loop()
        resp = await loop.run_in_executor(
            None,
            lambda: self._stub.LookupFunction(
                pb.LookupFunctionRequest(fn_name=fn),
                metadata=self._metadata(),
                timeout=10,
            ),
        )
        if not resp.found or not resp.endpoints.endpoints:
            raise ServiceBridgeError(
                f"no endpoints for '{fn}'",
                component="rpc", operation=fn, severity="retriable", retryable=True,
            )

        # Build comma-separated address list for round-robin LB.
        endpoints = [ep.endpoint.replace("grpc://", "") for ep in resp.endpoints.endpoints if ep.endpoint]
        # Use the first endpoint for the channel target; grpc handles LB via service config.
        target = endpoints[0]
        ch = grpc.insecure_channel(
            target,
            options=[("grpc.service_config", '{"loadBalancingConfig":[{"round_robin":{}}]}')],
        )

        with self._fn_channels_lock:
            self._fn_channels[fn] = (ch, endpoints)

        # Start background endpoint refresh for this function.
        threading.Thread(
            target=self._refresh_fn_channel_loop,
            args=(fn,),
            daemon=True,
        ).start()

        return ch, pb_grpc.ServiceBridgeWorkerStub(ch)

    def _refresh_fn_channel_loop(self, fn: str) -> None:
        """Periodically refresh endpoints for a function and recreate channel if changed."""
        refresh_ms = self._opts.discovery_refresh_ms
        while not self._stop_event.wait(refresh_ms / 1000):
            if self._stub is None:
                continue
            try:
                resp = self._stub.LookupFunction(
                    pb.LookupFunctionRequest(fn_name=fn),
                    metadata=self._metadata(),
                    timeout=10,
                )
                if not resp.found or not resp.endpoints.endpoints:
                    continue
                new_eps = [ep.endpoint.replace("grpc://", "") for ep in resp.endpoints.endpoints if ep.endpoint]
                with self._fn_channels_lock:
                    if fn not in self._fn_channels:
                        break  # channel was closed
                    _, old_eps = self._fn_channels[fn]
                    if set(new_eps) != set(old_eps):
                        # Endpoints changed — recreate channel.
                        old_ch, _ = self._fn_channels[fn]
                        new_ch = grpc.insecure_channel(
                            new_eps[0],
                            options=[("grpc.service_config", '{"loadBalancingConfig":[{"round_robin":{}}]}')],
                        )
                        self._fn_channels[fn] = (new_ch, new_eps)
                        old_ch.close()
            except Exception:
                pass

    # ─── Telemetry ────────────────────────────────────────────────────────────

    def _report_call_start(
        self, trace_id, span_id, parent_span_id, fn, started_at, input_buf, attempt
    ) -> None:
        if self._stub is None:
            return

        with self._queue_lock:
            online = self._is_online

        if not online:
            self._enqueue_offline({
                "kind": "reportCallStart",
                "trace_id": trace_id, "span_id": span_id,
                "parent_span_id": parent_span_id,
                "fn": fn, "started_at": started_at,
                "input": input_buf, "attempt": attempt,
            })
            return

        try:
            self._stub.ReportCallStart(
                pb.ReportCallStartRequest(
                    trace_id=trace_id, span_id=span_id,
                    parent_span_id=parent_span_id, fn=fn,
                    service_name=self._service_name, instance_id=self._instance_id,
                    started_at=started_at, input=input_buf, attempt=attempt,
                ),
                metadata=self._metadata(), timeout=5,
            )
        except Exception:
            pass

    def _report_call(
        self, trace_id, span_id, fn, started_at, input_buf, success, attempt, output_buf, error_msg
    ) -> None:
        if self._stub is None:
            return
        duration_ms = int(time.time() * 1000) - started_at

        with self._queue_lock:
            online = self._is_online

        if not online:
            self._enqueue_offline({
                "kind": "reportCall",
                "trace_id": trace_id, "span_id": span_id, "fn": fn,
                "started_at": started_at, "input": input_buf, "success": success,
                "attempt": attempt, "output": output_buf or b"", "error": error_msg,
            })
            return

        try:
            self._stub.ReportCall(
                pb.ReportCallRequest(
                    trace_id=trace_id, span_id=span_id, fn=fn,
                    service_name=self._service_name, instance_id=self._instance_id,
                    started_at=started_at, duration_ms=duration_ms,
                    success=success, error=error_msg,
                    input=input_buf, output=output_buf or b"", attempt=attempt,
                ),
                metadata=self._metadata(), timeout=5,
            )
        except Exception:
            pass

    # ─── Schema encode/decode helpers ─────────────────────────────────────────

    def _encode_payload(self, fn: str, payload: Any) -> bytes:
        """Encode payload to bytes. Uses binary Protobuf if a schema is registered."""
        opts = self._schemas.get(fn)
        if opts and opts.input:
            try:
                from service_bridge.schema import compile_schema, encode_with_schema
                md = self._get_compiled_schema(fn, "input", opts.input)
                if md:
                    return encode_with_schema(md, payload)
            except Exception as exc:
                _sdk_logger.debug("schema encode failed for %r: %s — falling back to JSON", fn, exc)
        return json.dumps(payload).encode() if payload is not None else b"{}"

    def _decode_output(self, fn: str, raw: bytes) -> Any:
        """Decode output bytes. Uses binary Protobuf if a schema is registered."""
        if not raw:
            return {}
        opts = self._schemas.get(fn)
        if opts and opts.output:
            try:
                from service_bridge.schema import decode_with_schema
                md = self._get_compiled_schema(fn, "output", opts.output)
                if md:
                    return decode_with_schema(md, raw)
            except Exception as exc:
                _sdk_logger.debug("schema decode failed for %r: %s — falling back to JSON", fn, exc)
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            return {}

    def _get_compiled_schema(self, fn: str, direction: str, schema: RpcSchema) -> Any:
        key = f"{fn}:{direction}"
        if key not in self._compiled_schemas:
            try:
                from service_bridge.schema import compile_schema
                self._compiled_schemas[key] = compile_schema(f"{fn}_{direction}", schema)
            except Exception:
                self._compiled_schemas[key] = None
        return self._compiled_schemas[key]

    # ─── Internal helpers ──────────────────────────────────────────────────────

    def _admin_base(self) -> str:
        if self._opts.admin_url:
            return self._opts.admin_url
        host = self._grpc_url.split(":")[0]
        return f"http://{host}:14444"

    def _metadata(self) -> list[tuple[str, str]]:
        return [("x-service-key", self._service_key)]

    async def _connect_control_plane(self, skip_tls: bool) -> None:
        channel_creds: Optional[grpc.ChannelCredentials] = None

        if not skip_tls:
            from service_bridge.tls import provision_tls
            creds = provision_tls(self._admin_base(), self._service_name, self._service_key)
            if creds:
                ssl_creds = grpc.ssl_channel_credentials(
                    root_certificates=creds.ca_cert,
                    private_key=creds.client_key,
                    certificate_chain=creds.client_cert,
                )
                channel_creds = ssl_creds

        if channel_creds:
            self._channel = grpc.secure_channel(self._grpc_url, channel_creds)
        else:
            self._channel = grpc.insecure_channel(self._grpc_url)

        self._stub = pb_grpc.ServiceBridgeStub(self._channel)

    async def _start_worker_server(self, host: str) -> str:
        from service_bridge.worker import WorkerServicer

        server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
        pb_grpc.add_ServiceBridgeWorkerServicer_to_server(WorkerServicer(self), server)
        port = server.add_insecure_port(f"{host}:0")
        server.start()
        self._worker_server = server
        return f"grpc://{host}:{port}"

    async def _sync_registrations(self) -> None:
        if self._stub is None:
            return
        md = self._metadata()
        fn_names = []
        group_names = []

        for fn, handler in self._rpc_handlers.items():
            opts = self._rpc_opts.get(fn, {})
            schema_opts = opts.get("schema")
            input_schema_json = ""
            output_schema_json = ""
            if schema_opts and schema_opts.input:
                input_schema_json = json.dumps({
                    k: {"type": v.type, "id": v.id, "repeated": v.repeated}
                    for k, v in schema_opts.input.items()
                })
            if schema_opts and schema_opts.output:
                output_schema_json = json.dumps({
                    k: {"type": v.type, "id": v.id, "repeated": v.repeated}
                    for k, v in schema_opts.output.items()
                })
            try:
                self._stub.RegisterFunction(
                    pb.RegisterFunctionRequest(
                        fn_name=fn,
                        service_name=self._service_name,
                        instance_id=self._instance_id,
                        endpoint=self._worker_address,
                        transport="grpc",
                        weight=100,
                        input_schema_json=input_schema_json,
                        output_schema_json=output_schema_json,
                    ),
                    metadata=md,
                    timeout=15,
                )
                fn_names.append(fn)
            except Exception as exc:
                logger.warning("RegisterFunction '%s': %s", fn, exc)

        for gname, entry in self._event_handlers.items():
            try:
                self._stub.RegisterConsumerGroup(
                    pb.RegisterConsumerGroupRequest(
                        name=gname,
                        pattern=entry["topic"],
                        mode="queue",
                        retry_policy_json=entry["retry_policy_json"],
                        active=True,
                        filter_expr=entry["filter_expr"],
                    ),
                    metadata=md,
                    timeout=15,
                )
                self._stub.RegisterGroupMember(
                    pb.RegisterGroupMemberRequest(
                        group_name=gname,
                        service_name=self._service_name,
                        instance_id=self._instance_id,
                        endpoint=self._worker_address,
                        transport="grpc",
                        weight=100,
                    ),
                    metadata=md,
                    timeout=15,
                )
                group_names.append(gname)
            except Exception as exc:
                logger.warning("RegisterConsumerGroup '%s': %s", gname, exc)

        # Process metrics (RAM via resource module, CPU best-effort).
        cpu_pct, ram_mb = _get_process_metrics()

        hb_req = pb.HeartbeatRequest(
            service_name=self._service_name,
            instance_id=self._instance_id,
            endpoint=self._worker_address,
            group_names=group_names,
            function_names=fn_names,
            transport="grpc",
        )
        if cpu_pct > 0:
            hb_req.cpu_percent = cpu_pct
        if ram_mb > 0:
            hb_req.ram_mb = ram_mb

        try:
            self._stub.Heartbeat(hb_req, metadata=md, timeout=15)
        except Exception as exc:
            if isinstance(exc, grpc.RpcError):
                code = exc.code()  # type: ignore[attr-defined]
                if code in (grpc.StatusCode.NOT_FOUND, grpc.StatusCode.FAILED_PRECONDITION):
                    logger.warning(
                        "Heartbeat: re-registration required (%s) — will re-sync on next cycle", code
                    )
                    return
            logger.warning("Heartbeat: %s", exc)

    def _start_heartbeat(self) -> None:
        interval_ms = self._opts.heartbeat_interval_ms

        def _loop() -> None:
            while not self._stop_event.is_set():
                # ±20% jitter on the heartbeat interval.
                jitter = random.uniform(-0.2, 0.2)
                delay = (interval_ms / 1000) * (1.0 + jitter)
                if self._stop_event.wait(delay):
                    return
                try:
                    asyncio.run(self._sync_registrations())
                except Exception as exc:
                    logger.warning("heartbeat error: %s", exc)
                    if _is_connection_error(exc):
                        self._mark_offline()

        self._heartbeat_thread = threading.Thread(target=_loop, daemon=True)
        self._heartbeat_thread.start()

    def _log_push(self, level: str, msg: str, attrs: dict[str, str]) -> None:
        """Internal: enqueue a log entry for batched delivery."""
        if not hasattr(self, "_log_batch"):
            self._log_batch = []
            self._log_lock = threading.Lock()
            self._log_timer = None

        tc = _trace_ctx.get({})
        entry = pb.LogEntry(
            service_name=self._service_name,
            instance_id=self._instance_id,
            level=level,
            message=msg,
            timestamp_ns=time.time_ns(),
            attributes=attrs,
            trace_id=tc.get("trace_id", ""),
            span_id=tc.get("span_id", ""),
        )
        flush_now = False
        with self._log_lock:
            self._log_batch.append(entry)
            if len(self._log_batch) >= 100:
                flush_now = True
            elif self._log_timer is None:
                t = threading.Timer(0.5, self._log_flush)
                t.daemon = True
                self._log_timer = t
                t.start()
        if flush_now:
            self._log_flush()

    def _log_flush(self) -> None:
        with self._log_lock:
            if not self._log_batch:
                return
            entries = list(self._log_batch)
            self._log_batch.clear()
            if self._log_timer is not None:
                self._log_timer.cancel()
                self._log_timer = None
        stub = self._stub
        if stub is None:
            return
        try:
            stub.ReportLog(
                pb.ReportLogRequest(entries=entries),
                metadata=self._metadata(),
                timeout=5,
            )
        except Exception as exc:
            _sdk_logger.debug("Log flush failed: %s", exc)

    def attach_logging_handler(
        self,
        logger_name: str = "",
        level: int = logging.NOTSET,
    ) -> "ServiceBridgeLogHandler":
        """Attach a :class:`ServiceBridgeLogHandler` to a Python standard logger.

        All records emitted by that logger (and its children) are forwarded to
        ServiceBridge automatically.  Original handlers are **not** removed.

        Args:
            logger_name: Name of the logger (empty = root logger = capture all).
            level: Minimum log level to forward (default: NOTSET = everything).

        Returns:
            The installed :class:`ServiceBridgeLogHandler` instance.
        """
        handler = ServiceBridgeLogHandler(self, level=level)
        logging.getLogger(logger_name).addHandler(handler)
        return handler


# ─── Process metrics ──────────────────────────────────────────────────────────

def _get_process_metrics() -> tuple[float, int]:
    """Return (cpu_percent, ram_mb). Best-effort; zero if unavailable."""
    ram_mb = 0
    cpu_pct = 0.0
    try:
        import resource
        usage = resource.getrusage(resource.RUSAGE_SELF)
        # ru_maxrss is in bytes on Linux, KB on macOS
        import sys
        if sys.platform == "darwin":
            ram_mb = int(usage.ru_maxrss / (1024 * 1024))
        else:
            ram_mb = int(usage.ru_maxrss / 1024)
    except Exception:
        pass
    return cpu_pct, ram_mb


# ─── Async gRPC stream helper ─────────────────────────────────────────────────

async def _aiter_grpc_stream(stream_call: Any, loop: asyncio.AbstractEventLoop) -> AsyncIterator:
    """Wrap a synchronous gRPC server-streaming call as an async generator.

    Runs the blocking iteration in a background thread and communicates via
    an asyncio.Queue with a 256-item backpressure limit.
    """
    q: asyncio.Queue = asyncio.Queue(maxsize=256)
    _sentinel = object()

    def _reader() -> None:
        try:
            for chunk in stream_call:
                loop.call_soon_threadsafe(q.put_nowait, chunk)
        except Exception as exc:
            loop.call_soon_threadsafe(q.put_nowait, exc)
        finally:
            loop.call_soon_threadsafe(q.put_nowait, _sentinel)

    threading.Thread(target=_reader, daemon=True).start()

    while True:
        item = await q.get()
        if item is _sentinel:
            return
        if isinstance(item, Exception):
            raise item
        yield item


# ─── Workflow step helpers ────────────────────────────────────────────────────

def _step_to_dict(s: WorkflowStep) -> dict[str, Any]:
    d: dict[str, Any] = {"id": s.id, "type": s.type}
    if s.ref:
        d["ref"] = s.ref
    if s.deps:
        d["deps"] = s.deps
    if s.if_expr:
        d["if"] = s.if_expr
    if s.timeout_ms:
        d["timeout_ms"] = s.timeout_ms
    if s.duration_ms:
        d["duration_ms"] = s.duration_ms
    return d


# ─── Standard logging handler ─────────────────────────────────────────────────

_LEVEL_MAP: dict[int, str] = {
    logging.DEBUG: "DEBUG",
    logging.INFO: "INFO",
    logging.WARNING: "WARN",
    logging.ERROR: "ERROR",
    logging.CRITICAL: "ERROR",
}


class ServiceBridgeLogHandler(logging.Handler):
    """A :class:`logging.Handler` that ships Python log records to ServiceBridge.

    Records are batched and flushed every 500ms or when 100 records accumulate.
    Original handlers are preserved — logs still appear on the console.
    """

    def __init__(self, svc: "ServiceBridge", level: int = logging.NOTSET) -> None:
        super().__init__(level)
        self._svc = svc

    def emit(self, record: logging.LogRecord) -> None:
        try:
            msg = self.format(record)
            sb_level = _LEVEL_MAP.get(record.levelno, "INFO")
            attrs: dict[str, str] = {"logger": record.name}
            if record.exc_info:
                import traceback as _tb
                attrs["exception"] = "".join(_tb.format_exception(*record.exc_info)).strip()
            self._svc._log_push(sb_level, msg, attrs)
        except Exception:
            self.handleError(record)
