#!/usr/bin/env python3
# omnilink.py — OmniLink engine with MQTT bridge helpers
# Dependencies: paho-mqtt  (pip install paho-mqtt)

from __future__ import annotations

from omnilink.client import OmniLinkAPIError, OmniLinkClient  # noqa: F401
from omnilink.tool_runner import ToolRunner  # noqa: F401

import http.server as _http_server
import atexit
import contextlib
import json
import logging
import os
import queue
import re
import shutil
import socket
import subprocess
import tempfile
import threading
import time
import uuid
from collections import Counter, deque
from dataclasses import dataclass
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Deque,
    Dict,
    Iterable,
    List,
    Optional,
    Pattern,
    Protocol,
    Sequence,
    Set,
    Tuple,
    Union,
)
import textwrap

try:  # pragma: no cover - optional dependency
    import requests  # type: ignore[import-not-found]
    from requests.adapters import HTTPAdapter  # type: ignore[import-not-found]
except Exception:  # pragma: no cover - optional dependency
    requests = None  # type: ignore[assignment]
    HTTPAdapter = None  # type: ignore[assignment]

try:  # pragma: no cover - optional dependency
    from urllib3.util.retry import Retry  # type: ignore[import-not-found]
except Exception:  # pragma: no cover - optional dependency
    Retry = None  # type: ignore[assignment]

if TYPE_CHECKING:  # pragma: no cover - typing aid
    import requests as _requests


RequestException = requests.RequestException if requests else Exception

# =========================================================
# Utilities / Types
# =========================================================

def _json_safe_copy(data: Any) -> Any:
    """Return a JSON-serializable deep copy of ``data`` using ``str`` fallbacks."""

    try:
        return json.loads(json.dumps(data, default=str))
    except Exception:
        # As a last resort, stringify the object itself.
        return json.loads(json.dumps(str(data)))


TypeConverter = Callable[[str], Any]
InlineCodeHandler = Callable[["InlineCodeMessage"], None]


@dataclass
class AgentFeedback:
    """Structured feedback emitted by command handlers."""

    message: str
    kind: str = "info"
    ok: Optional[bool] = None
    progress: Optional[float] = None
    data: Optional[Dict[str, Any]] = None

    def to_payload(self, *, command: Optional[str] = None) -> Dict[str, Any]:
        payload: Dict[str, Any] = {"message": self.message}
        if command:
            payload["command"] = command
        if self.kind != "info":
            payload["kind"] = self.kind
        if self.ok is not None:
            payload["ok"] = self.ok
        if self.progress is not None:
            payload["progress"] = self.progress
        if self.data:
            payload["data"] = self.data
        return payload

    def to_legacy_payload(self) -> Dict[str, Any]:
        payload: Dict[str, Any] = {
            "feedback": True,
            "ok": self.ok if self.ok is not None else True,
            "message": self.message,
        }
        if self.kind != "info":
            payload["kind"] = self.kind
        if self.progress is not None:
            payload["progress"] = self.progress
        if self.data:
            payload["data"] = self.data
        return payload


@dataclass
class AgentQuestion:
    """Question payload that can optionally include inline code suggestions."""

    prompt: str
    choices: Optional[Sequence[str]] = None
    allow_multiple: bool = False
    inline_code: Optional["InlineCodeMessage"] = None
    data: Optional[Dict[str, Any]] = None

    def to_payload(self, *, command: Optional[str] = None) -> Dict[str, Any]:
        payload: Dict[str, Any] = {"prompt": self.prompt}
        if command:
            payload["command"] = command
        if self.choices:
            payload["choices"] = [str(choice) for choice in self.choices]
        if self.allow_multiple:
            payload["allowMultiple"] = True
        if self.inline_code:
            payload["inlineCodeMessage"] = self.inline_code.to_payload()
        if self.data:
            payload["data"] = self.data
        return payload


class PendingQuestion:
    """Represents a question awaiting an external reply."""

    def __init__(self, engine: "OmniLinkEngine", correlation_id: str) -> None:
        self._engine = engine
        self.correlation_id = correlation_id
        self._event = threading.Event()
        self._response: Optional[Dict[str, Any]] = None
        self._error: Optional[BaseException] = None

    def wait(
        self,
        timeout: Optional[float] = None,
        *,
        cancel_on_timeout: bool = False,
        cancel_message: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Block until an answer is received or timeout expires.

        When ``cancel_on_timeout`` is true, timing out will also cancel the
        pending question so late replies are ignored.
        """

        if not self._event.wait(timeout):
            if cancel_on_timeout:
                self.cancel(cancel_message or "Timed out waiting for agent reply")
            raise TimeoutError(
                f"Timed out waiting for agent reply (correlation_id={self.correlation_id})"
            )
        if self._error is not None:
            raise self._error
        return dict(self._response or {})

    def cancel(self, message: Optional[str] = None) -> None:
        """Cancel this question so future replies are ignored."""

        self._engine._cancel_pending_question(self.correlation_id, message)

    def done(self) -> bool:
        """Return ``True`` if a reply (or cancellation) has been recorded."""

        return self._event.is_set()

    def result(self) -> Optional[Dict[str, Any]]:
        """Return the reply payload if one has been received."""

        return dict(self._response) if self._response is not None else None

    # Internal helpers -------------------------------------------------
    def _resolve(self, payload: Dict[str, Any]) -> None:
        self._response = dict(payload)
        self._event.set()

    def _reject(self, message: Optional[str]) -> None:
        self._error = RuntimeError(message or "Question cancelled")
        self._event.set()


class AgentMessenger(Protocol):
    """Interface for emitting structured agent updates while handling commands."""

    def send_feedback(self, feedback: AgentFeedback) -> None:
        ...

    def ask_question(self, question: AgentQuestion) -> PendingQuestion:
        ...

    def acknowledge(
        self,
        status: str,
        *,
        message: Optional[str] = None,
        data: Optional[Dict[str, Any]] = None,
    ) -> None:
        ...


LOGGER = logging.getLogger(__name__)

def _num_conv(s: str) -> Union[int, float]:
    if re.fullmatch(r"-?\d+", s):
        return int(s)
    f = float(s)
    return int(f) if f.is_integer() else f

def _normalize_separators(text: str) -> str:
    """
    Normalize separators for matching:
      - strip
      - collapse whitespace to single space
      - convert spaces to underscores
    NOTE: we DO NOT lowercase to preserve captured values like 'C2'.
    """
    t = re.sub(r"\s+", " ", text.strip())
    return t.replace(" ", "_")


def _env_flag(name: str, default: bool) -> bool:
    value = os.environ.get(name)
    if value is None:
        return default
    return value.strip().lower() not in ("0", "false", "no", "off")


def _remote_rest_endpoint(base_url: str) -> str:
    base = base_url.rstrip("/")
    return f"{base}/rest/v1/command_outputs"


DEFAULT_REMOTE_TIMEOUT = 10

# =========================================================
# Type registry
# =========================================================

class TypeRegistry:
    """
    Holds named types mapping to (regex, converter).
    Converters receive str and return a typed value (or raise ValueError).
    """
    def __init__(self) -> None:
        self._types: Dict[str, Tuple[str, Optional[TypeConverter]]] = {}
        self._install_defaults()

    def _install_defaults(self) -> None:
        # Common text
        self.register("alpha", r"[A-Za-z]+")
        self.register("letters", r"[A-Za-z]+")
        self.register("word", r"[A-Za-z]+")
        self.register("lower", r"[a-z]+")
        self.register("upper", r"[A-Z]+")
        self.register("slug", r"[A-Za-z0-9_-]+")
        self.register("alnum", r"[A-Za-z0-9]+")
        self.register("alphanumeric", r"[A-Za-z0-9]+")

        # Numbers / bools
        self.register("int", r"-?\d+", lambda s: int(s))
        self.register("float", r"-?\d+(?:\.\d+)?", lambda s: float(s))
        self.register("num", r"-?\d+(?:\.\d+)?", _num_conv)
        self.register("digit", r"\d", lambda s: int(s))
        self.register("digits", r"\d+", lambda s: int(s))
        self.register("bool", r"(?:true|false|0|1)", lambda s: s.lower() in ("true", "1"))

        # IDs / time-ish (kept as strings)
        self.register("uuid", r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}")
        self.register("date", r"\d{4}-\d{2}-\d{2}")
        self.register("time", r"\d{2}:\d{2}(?::\d{2})?")
        self.register("datetime", r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z?")

        # Anything (greedy)
        self.register("any", r".+")

    def register(self, name: str, regex: str, converter: Optional[TypeConverter] = None) -> None:
        self._types[name.lower()] = (regex, converter)

    def get(self, name: str) -> Optional[Tuple[str, Optional[TypeConverter]]]:
        return self._types.get(name.lower())

    def available(self) -> Dict[str, str]:
        return {k: v[0] for k, v in self._types.items()}

# =========================================================
# Pattern compilation and matching
# =========================================================

# Token format inside []:
#   [name:type]      named + typed
#   [name]           named, default token regex
#   [:type]          unnamed, typed
#   [/regex/]        unnamed, custom regex
#   [name:/regex/]   named, custom regex
_TOKEN_PAT = re.compile(r"\[([^\]]+)\]")   # inner contents only
_DEFAULT_TOKEN_RX = r"[^_]+"               # stops at underscore

@dataclass
class CompiledPattern:
    template: str
    regex: Pattern[str]
    var_names: List[str]
    var_types: List[Optional[str]]
    text: str  # normalized template text

def _parse_token(token: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
    """
    Returns (name, type_name, regex_override)
    """
    token = token.strip()
    if token.startswith("/") and token.endswith("/") and len(token) > 2:
        return None, None, token[1:-1]
    if ":" in token:
        left, right = token.split(":", 1)
        left, right = left.strip(), right.strip()
        if right.startswith("/") and right.endswith("/") and len(right) > 2:
            name = left or None
            return name, None, right[1:-1]
        name = left or None
        typ = right or None
        return name, typ, None
    if token:
        return token, None, None
    return None, None, None

def _compile_template(template: str, types: TypeRegistry) -> CompiledPattern:
    """
    Compile a human-readable template into a strict regex with capturing groups.
    Both template and inputs are normalized with _normalize_separators (NOT lowercased).
    Regex is compiled with IGNORECASE; captures preserve original case.
    """
    norm = _normalize_separators(template)
    var_names: List[str] = []
    var_types: List[Optional[str]] = []
    pieces: List[str] = []
    last = 0

    for m in _TOKEN_PAT.finditer(norm):
        pieces.append(re.escape(norm[last:m.start()]))

        name, typ, rx_override = _parse_token(m.group(1))

        if rx_override:
            cap_rx = f"({rx_override})"
        elif typ:
            spec = types.get(typ)
            if not spec:
                raise ValueError(f"Unknown type '{typ}' in template: {template}")
            rx, _ = spec
            cap_rx = f"({rx})"
        else:
            cap_rx = f"({_DEFAULT_TOKEN_RX})"

        if not name:
            name = f"var{len(var_names) + 1}"

        var_names.append(name)
        var_types.append(typ)
        pieces.append(cap_rx)
        last = m.end()

    pieces.append(re.escape(norm[last:]))

    full_rx = "^" + "".join(pieces) + "$"
    return CompiledPattern(
        template=template,
        regex=re.compile(full_rx, flags=re.IGNORECASE),
        var_names=var_names,
        var_types=var_types,
        text=norm,
    )

# =========================================================
# Engine
# =========================================================

@dataclass
class Event:
    command: str
    text: str
    template: Optional[str]
    normalized_template: Optional[str]
    vars: Dict[str, Any]
    errors: List[Dict[str, Any]]
    meta: Dict[str, Any]
    timestamp: float
    messenger: Optional[AgentMessenger] = None

Handler = Callable[[Dict[str, Any]], Any]
Predicate = Callable[[Dict[str, Any]], bool]

class OmniLinkEngine:
    """
    Pattern-driven command engine with routing, middleware, metrics, and history.
    """
    def __init__(self, patterns: Iterable[str], types: Optional[TypeRegistry] = None, keep_history: int = 200) -> None:
        self.types = types or TypeRegistry()
        self._compiled: List[CompiledPattern] = []
        self._templates: List[str] = []
        self._handlers: List[Tuple[Predicate, Handler]] = []
        self._before: List[Handler] = []
        self._after: List[Handler] = []
        self._messenger: Optional[AgentMessenger] = None

        self.metrics = Counter()
        self.history: Deque[Event] = deque(maxlen=keep_history)

        self._pending_questions: Dict[str, PendingQuestion] = {}
        self._pending_lock = threading.Lock()

        for t in patterns:
            self.add_template(t)

    # Templates
    def add_template(self, template: str) -> None:
        cp = _compile_template(template, self.types)
        self._compiled.append(cp)
        self._templates.append(template)

    @property
    def templates(self) -> List[str]:
        return list(self._templates)

    # Routing
    def on(self, predicate: Predicate, handler: Handler) -> None:
        self._handlers.append((predicate, handler))

    def on_template(self, template: str, handler: Handler) -> None:
        norm = _normalize_separators(template)
        self._handlers.append((lambda e: e.get("normalized_template") == norm, handler))

    def before(self, handler: Handler) -> None:
        self._before.append(handler)

    def after(self, handler: Handler) -> None:
        self._after.append(handler)

    # Parsing and handling
    def parse(self, text: str) -> Dict[str, Any]:
        tnorm = _normalize_separators(text)
        conversion_errors: List[Dict[str, Any]] = []
        for cp in self._compiled:
            m = cp.regex.match(tnorm)
            if not m:
                continue
            values = m.groups()
            out: Dict[str, Any] = {}
            for name, typ, val in zip(cp.var_names, cp.var_types, values):
                if typ:
                    spec = self.types.get(typ)
                    if spec and spec[1]:
                        try:
                            out[name] = spec[1](val)
                        except Exception as exc:
                            LOGGER.warning(
                                "Type conversion failed",
                                extra={
                                    "template": cp.template,
                                    "variable": name,
                                    "type": typ,
                                    "value": val,
                                    "error": str(exc),
                                },
                                exc_info=True,
                            )
                            out[name] = val
                            conversion_errors.append(
                                {
                                    "variable": name,
                                    "type": typ,
                                    "value": val,
                                    "error": str(exc),
                                }
                            )
                    else:
                        out[name] = val
                else:
                    out[name] = val
            return {
                "ok": True,
                "template": cp.template,
                "normalized_template": cp.text,
                "vars": out,
                "errors": conversion_errors,
            }
        return {
            "ok": False,
            "template": None,
            "normalized_template": None,
            "vars": {},
            "errors": [],
        }

    # State snapshots ---------------------------------------------------
    def _serialize_event(self, event: Event, *, include_messenger: bool = False) -> Dict[str, Any]:
        payload: Dict[str, Any] = {
            "command": event.command,
            "text": event.text,
            "template": event.template,
            "normalized_template": event.normalized_template,
            "vars": event.vars,
            "errors": event.errors,
            "meta": event.meta,
            "timestamp": event.timestamp,
        }
        if include_messenger:
            payload["messenger"] = str(event.messenger) if event.messenger is not None else None
        return _json_safe_copy(payload)

    def get_recent_events(
        self,
        limit: Optional[int] = None,
        *,
        newest_first: bool = True,
        include_messenger: bool = False,
    ) -> List[Dict[str, Any]]:
        """Return a JSON-serializable snapshot of recent history events."""

        events: Sequence[Event] = list(self.history)
        if limit is not None and limit >= 0:
            events = events[-limit:]
        if newest_first:
            events = list(reversed(events))
        return [self._serialize_event(evt, include_messenger=include_messenger) for evt in events]

    def get_metrics_snapshot(self) -> Dict[str, int]:
        """Return a JSON-serializable copy of current metrics counters."""

        return _json_safe_copy(dict(self.metrics))

    def get_context(
        self,
        *,
        history_limit: Optional[int] = 10,
        include_metrics: bool = True,
        include_history: bool = True,
        newest_first: bool = True,
        include_messenger: bool = False,
    ) -> Dict[str, Any]:
        """Return a combined snapshot of metrics and recent history."""

        context: Dict[str, Any] = {"generated_at": time.time()}
        if include_metrics:
            context["metrics"] = self.get_metrics_snapshot()
        if include_history and history_limit != 0:
            limit = history_limit if history_limit is None else max(history_limit, 0)
            context["recent_events"] = self.get_recent_events(
                limit=limit,
                newest_first=newest_first,
                include_messenger=include_messenger,
            )
        return _json_safe_copy(context)

    def set_messenger(self, messenger: Optional[AgentMessenger]) -> None:
        """Register a default messenger used when ``handle`` is called without one."""

        self._messenger = messenger

    # Questions ---------------------------------------------------------
    def create_question_waiter(self) -> PendingQuestion:
        """Create and track a ``PendingQuestion`` for external replies."""

        correlation_id = uuid.uuid4().hex
        pending = PendingQuestion(self, correlation_id)
        with self._pending_lock:
            self._pending_questions[correlation_id] = pending
        return pending

    def receive_agent_reply(self, reply: Dict[str, Any]) -> bool:
        """Resolve a pending question from an agent reply payload."""

        correlation_id = self._extract_correlation_id(reply)
        if not correlation_id:
            return False
        with self._pending_lock:
            pending = self._pending_questions.pop(correlation_id, None)
        if not pending:
            return False
        pending._resolve(reply)
        return True

    def _extract_correlation_id(self, reply: Dict[str, Any]) -> Optional[str]:
        value: Optional[str] = None
        for key in ("correlationId", "correlation_id", "id"):
            raw = reply.get(key)
            if isinstance(raw, str) and raw.strip():
                value = raw.strip()
                break
        if value:
            return value
        data = reply.get("data")
        if isinstance(data, dict):
            for key in ("correlationId", "correlation_id"):
                raw = data.get(key)
                if isinstance(raw, str) and raw.strip():
                    return raw.strip()
        return None

    def _cancel_pending_question(self, correlation_id: str, message: Optional[str] = None) -> None:
        with self._pending_lock:
            pending = self._pending_questions.pop(correlation_id, None)
        if pending:
            pending._reject(message)

    def handle(
        self,
        text: str,
        meta: Optional[Dict[str, Any]] = None,
        messenger: Optional[AgentMessenger] = None,
    ) -> Dict[str, Any]:
        meta = meta or {}
        ts = time.time()
        parsed = self.parse(text)
        active_messenger = messenger if messenger is not None else self._messenger
        evt: Dict[str, Any] = {
            "command": text,
            "text": _normalize_separators(text),
            "template": parsed.get("template"),
            "normalized_template": parsed.get("normalized_template"),
            "vars": parsed.get("vars", {}),
            "errors": parsed.get("errors", []),
            "meta": meta,
            "timestamp": ts,
            "messenger": active_messenger,
        }

        # history + metrics
        self.history.append(Event(**evt))
        self.metrics["handle.calls"] += 1
        self.metrics[f"handle.ok.{bool(parsed.get('ok'))}"] += 1
        template_key = evt["normalized_template"] or "<none>"
        self.metrics[f"handle.template.{template_key}"] += 1
        if evt["errors"]:
            self.metrics["handle.parse_errors"] += len(evt["errors"])

        # before middleware
        for h in self._before:
            try:
                h(evt)
            except Exception as e:
                logging.exception("Error in before-handler: %s", e)

        # routing
        result: Any = None
        matched_handler = False
        for pred, handler in self._handlers:
            try:
                if pred(evt):
                    matched_handler = True
                    self.metrics["handle.matched"] += 1
                    self.metrics[f"handle.matched.{template_key}"] += 1
                    result = handler(evt)
                    break
            except Exception as e:
                LOGGER.exception("Handler error", extra={"template": evt["template"]})
                result = {"error": str(e)}
                break

        if not matched_handler:
            self.metrics["handle.unmatched"] += 1
            LOGGER.info(
                "No handler matched event",
                extra={
                    "command": evt["command"],
                    "template": evt["template"],
                    "normalized_template": evt["normalized_template"],
                },
            )

        # after middleware
        for h in self._after:
            try:
                h(evt)
            except Exception as e:
                LOGGER.exception("Error in after-handler", extra={"template": evt["template"]})

        return {
            "ok": bool(parsed.get("ok")),
            "template": evt["template"],
            "normalized_template": evt["normalized_template"],
            "vars": evt["vars"],
            "errors": evt["errors"],
            "result": result,
            "meta": meta,
            "timestamp": ts,
        }

# =========================================================
# Pattern file loader (exported)
# =========================================================

from inspect import getsourcefile, signature, stack

def load_patterns_from_file(path: Union[str, Path], types: Optional[TypeRegistry] = None) -> List[str]:
    """
    Lines are templates; '#' starts a comment. Blank lines ignored.
    Robustness features:
      - Resolves relative paths against the caller's directory if needed.
      - Reads with utf-8-sig to ignore BOMs.
      - Auto-unquotes lines wrapped in "..." or '...'.
    """
    p = Path(path)
    tried: List[Path] = []

    def _read_file(pp: Path) -> List[str]:
        text = pp.read_text(encoding="utf-8-sig")
        out: List[str] = []
        for line in text.splitlines():
            s = line.strip()
            if not s or s.startswith("#"):
                continue
            if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
                s = s[1:-1].strip()
            if s:
                out.append(s)
        return out

    # 1) as-is
    tried.append(p)
    if p.exists():
        return _read_file(p)

    # 2) relative to caller
    try:
        caller_file = getsourcefile(stack()[1].frame) or ""
        base = Path(caller_file).resolve().parent if caller_file else Path.cwd()
    except Exception:
        base = Path.cwd()
    p2 = (base / p).resolve()
    if p2.exists():
        tried.append(p2)
        return _read_file(p2)

    # 3) relative to CWD
    p3 = Path.cwd() / p
    if p3.exists():
        tried.append(p3)
        return _read_file(p3)

    raise FileNotFoundError(f"Patterns file not found. Tried: {', '.join(str(x) for x in tried)}")


# =========================================================
# Remote command helpers (Supabase REST API)
# =========================================================


class RemoteCommandClient:
    """Small helper for fetching and updating remote commands via Supabase."""

    ENV_BASE_URL = "OmniLink_REMOTE_BASE_URL"
    ENV_ANON_KEY = "OmniLink_REMOTE_ANON_KEY"
    ENV_USER_KEY = "OmniLink_REMOTE_USER_KEY"

    def __init__(
        self,
        *,
        base_url: Optional[str] = None,
        anon_key: Optional[str] = None,
        user_key: Optional[str] = None,
        session: Optional[requests.Session] = None,
        timeout: int = DEFAULT_REMOTE_TIMEOUT,
    ) -> None:
        if requests is None:
            raise RuntimeError(
                "requests is required for RemoteCommandClient; install it to enable remote commands."
            )
        if base_url is None:
            base_url = os.environ.get(self.ENV_BASE_URL)
        if anon_key is None:
            anon_key = os.environ.get(self.ENV_ANON_KEY)
        if user_key is None:
            user_key = os.environ.get(self.ENV_USER_KEY)

        missing: List[str] = []
        if not base_url:
            missing.append(self.ENV_BASE_URL)
        if not anon_key:
            missing.append(self.ENV_ANON_KEY)
        if not user_key:
            missing.append(self.ENV_USER_KEY)
        if missing:
            raise RuntimeError(
                "RemoteCommandClient missing configuration; set environment variables "
                + ", ".join(missing)
            )

        self.base_url = base_url
        self.anon_key = anon_key
        self.user_key = user_key
        self.timeout = timeout
        self._endpoint = _remote_rest_endpoint(base_url)
        self._session = session or self._build_session()

    def _build_session(self) -> requests.Session:
        session = requests.Session()
        if HTTPAdapter is not None and Retry is not None:
            retry = Retry(
                total=3,
                backoff_factor=0.5,
                status_forcelist=(408, 429, 500, 502, 503, 504),
                allowed_methods=("GET", "PATCH"),
            )
            adapter = HTTPAdapter(max_retries=retry)
            session.mount("http://", adapter)
            session.mount("https://", adapter)
        return session

    def close(self) -> None:
        self._session.close()

    def __enter__(self) -> "RemoteCommandClient":
        return self

    def __exit__(self, exc_type, exc, tb) -> None:
        self.close()

    def _headers(
        self,
        *,
        accept: str = "application/json",
        content_type: Optional[str] = None,
        extra: Optional[Dict[str, str]] = None,
    ) -> Dict[str, str]:
        headers = {
            "apikey": self.anon_key,
            "Authorization": f"Bearer {self.anon_key}",
            "X-Client-User-Key": self.user_key,
        }
        if accept:
            headers["Accept"] = accept
        if content_type:
            headers["Content-Type"] = content_type
        if extra:
            headers.update(extra)
        return headers

    def fetch_last_command(self) -> Optional[Dict[str, Any]]:
        """Return the most recent command for the configured user, if available."""

        params = {
            "select": "user_key,last_command,last_response,updated_at",
            "user_key": f"eq.{self.user_key}",
            "order": "updated_at.desc",
            "limit": 1,
        }
        response = self._session.get(
            self._endpoint,
            params=params,
            headers=self._headers(),
            timeout=self.timeout,
        )
        response.raise_for_status()
        try:
            data = response.json()
        except ValueError:
            LOGGER.exception("Supabase returned non-JSON payload")
            raise
        if isinstance(data, list):
            if data:
                return data[0]
            return None
        LOGGER.warning(
            "Unexpected Supabase response structure",
            extra={"response_type": type(data).__name__},
        )
        return None

    def update_last_response(self, response_text: str, *, last_command: Optional[str] = None) -> None:
        """Persist ``response_text`` for the current user."""

        payload: Dict[str, Any] = {"last_response": response_text}
        if last_command is not None:
            payload["last_command"] = last_command
        resp = self._session.patch(
            self._endpoint,
            params={"user_key": f"eq.{self.user_key}"},
            headers=self._headers(
                content_type="application/json",
                extra={"Prefer": "return=minimal"},
            ),
            json=payload,
            timeout=self.timeout,
        )
        resp.raise_for_status()

    def fetch_state_snapshot(self) -> Optional[Dict[str, Any]]:
        """Return the most recently stored state snapshot, if available."""

        params = {
            "select": "state_snapshot,state_snapshot_updated_at,updated_at",
            "user_key": f"eq.{self.user_key}",
            "limit": 1,
        }
        response = self._session.get(
            self._endpoint,
            params=params,
            headers=self._headers(),
            timeout=self.timeout,
        )
        response.raise_for_status()
        try:
            data = response.json()
        except ValueError:
            LOGGER.exception("Supabase returned non-JSON payload for state snapshot")
            raise

        record: Optional[Dict[str, Any]]
        if isinstance(data, list):
            record = data[0] if data else None
        elif isinstance(data, dict):
            record = data
        else:
            record = None

        if not record:
            return None

        raw_snapshot = record.get("state_snapshot")
        if raw_snapshot is None:
            return None

        if isinstance(raw_snapshot, str):
            try:
                parsed_snapshot: Any = json.loads(raw_snapshot)
            except json.JSONDecodeError:
                parsed_snapshot = raw_snapshot
        else:
            parsed_snapshot = raw_snapshot

        snapshot = _json_safe_copy(parsed_snapshot)
        updated_at = record.get("state_snapshot_updated_at") or record.get("updated_at")
        if isinstance(updated_at, str) or updated_at is None:
            updated_value = updated_at
        else:
            updated_value = str(updated_at)

        return {"snapshot": snapshot, "updated_at": updated_value}

    def update_state_snapshot(self, snapshot: Any) -> None:
        """Persist a JSON-serializable snapshot payload for the current user."""

        payload = {"state_snapshot": _json_safe_copy(snapshot)}
        resp = self._session.patch(
            self._endpoint,
            params={"user_key": f"eq.{self.user_key}"},
            headers=self._headers(
                content_type="application/json",
                extra={"Prefer": "return=minimal"},
            ),
            json=payload,
            timeout=self.timeout,
        )
        resp.raise_for_status()


class OmniLinkRemoteCommandBridge:
    """Poll Supabase for commands and run them through an ``OmniLinkEngine``."""

    def __init__(
        self,
        engine: OmniLinkEngine,
        client: Optional[RemoteCommandClient] = None,
        *,
        poll_interval: float = 2.0,
        log: Optional[bool] = None,
    ) -> None:
        self.engine = engine
        self.client = client or RemoteCommandClient()
        self.poll_interval = poll_interval
        self.log = _env_flag("OmniLink_REMOTE_LOG", True) if log is None else log
        self._stop_event = threading.Event()
        self._thread: Optional[threading.Thread] = None
        self._last_signature: Optional[Tuple[str, str]] = None
        self._last_reply_signature: Optional[str] = None

    def _format_response(self, payload: Dict[str, Any]) -> str:
        try:
            return json.dumps(payload, ensure_ascii=False)
        except TypeError:
            return str(payload)

    def _extract_agent_replies(
        self, record: Dict[str, Any]
    ) -> Tuple[Optional[str], List[Dict[str, Any]]]:
        raw = record.get("last_response")
        if raw is None:
            return None, []

        parsed: Any
        signature: Optional[str] = None

        if isinstance(raw, str):
            text = raw.strip()
            if not text:
                return None, []
            try:
                parsed = json.loads(text)
            except json.JSONDecodeError:
                return None, []
        elif isinstance(raw, (dict, list)):
            parsed = raw
        else:
            return None, []

        try:
            signature = json.dumps(parsed, sort_keys=True)
        except TypeError:
            if isinstance(raw, str):
                signature = raw.strip() or None
            else:
                signature = None

        replies: List[Dict[str, Any]] = []
        if isinstance(parsed, dict):
            maybe_list = parsed.get("agentReplies")
            if isinstance(maybe_list, list):
                for item in maybe_list:
                    if isinstance(item, dict):
                        replies.append(item)
            single = parsed.get("agentReply")
            if isinstance(single, dict):
                replies.append(single)
        elif isinstance(parsed, list):
            for item in parsed:
                if isinstance(item, dict):
                    replies.append(item)

        return signature, replies

    def _deliver_agent_replies(self, replies: Sequence[Dict[str, Any]]) -> int:
        delivered = 0
        receive = getattr(self.engine, "receive_agent_reply", None)
        if not callable(receive):
            return delivered
        for payload in replies:
            try:
                if receive(payload):
                    delivered += 1
            except Exception:
                if self.log:
                    LOGGER.exception("Error delivering agent reply", extra={"payload": payload})
        if delivered and self.log:
            LOGGER.info("Remote agent replies delivered", extra={"count": delivered})
        return delivered

    def process_once(self) -> Optional[Dict[str, Any]]:
        """Fetch and handle the most recent command once."""

        try:
            record = self.client.fetch_last_command()
        except RequestException:
            if self.log:
                LOGGER.warning("Remote fetch error", exc_info=True)
            return None

        if not record:
            return None

        command = (record.get("last_command") or "").strip()
        updated_at = record.get("updated_at") or ""
        signature = (command, updated_at)

        previous_command = self._last_signature[0] if self._last_signature else None
        if previous_command is None or command != previous_command:
            self._last_reply_signature = None

        reply_signature, replies = self._extract_agent_replies(record)
        delivered_replies = 0
        if replies:
            if reply_signature and reply_signature == self._last_reply_signature:
                replies = []
            if replies:
                delivered_replies = self._deliver_agent_replies(replies)
                if delivered_replies and reply_signature:
                    self._last_reply_signature = reply_signature

        if not command or command.lower() == "none":
            if delivered_replies:
                self._last_signature = signature
                return {"agent_replies": delivered_replies}
            return None

        if self._last_signature and signature == self._last_signature:
            return {"agent_replies": delivered_replies} if delivered_replies else None

        if (
            self._last_signature
            and command == self._last_signature[0]
            and delivered_replies
            and signature != self._last_signature
        ):
            self._last_signature = signature
            return {"agent_replies": delivered_replies}

        meta = {
            "source": "remote",
            "remote": {
                "user_key": record.get("user_key"),
                "updated_at": record.get("updated_at"),
            },
        }
        result = self.engine.handle(command, meta=meta)

        response_payload = self._format_response(result)
        try:
            self.client.update_last_response(response_payload, last_command=command)
        except RequestException:
            if self.log:
                LOGGER.warning("Remote update error", exc_info=True)
        else:
            self._last_signature = signature
            if self.log:
                LOGGER.info(
                    "Remote command handled",
                    extra={
                        "command": command,
                        "response": response_payload,
                        "template": result.get("template"),
                    },
                )

        return result

    def loop_forever(self) -> None:
        """Continuously poll for commands until ``stop`` is called."""

        self._stop_event.clear()
        try:
            while not self._stop_event.is_set():
                self.process_once()
                self._stop_event.wait(self.poll_interval)
        except KeyboardInterrupt:
            pass

    def start(self) -> "OmniLinkRemoteCommandBridge":
        """Start polling in a background thread."""

        if self._thread and self._thread.is_alive():
            return self
        self._stop_event.clear()
        thread = threading.Thread(target=self.loop_forever, daemon=True)
        self._thread = thread
        thread.start()
        if self.log:
            LOGGER.info("Remote bridge background polling started")
        return self

    def stop(self) -> None:
        """Stop the background polling thread if running."""

        self._stop_event.set()
        if self._thread and self._thread.is_alive():
            self._thread.join(timeout=self.poll_interval * 2)
        self._thread = None


# =========================================================
# MQTT bridge + context publishing
# =========================================================
# Env (defaults tuned for Mosquitto w/ WebSockets):
#   MQTT_HOST              default: "localhost"
#   MQTT_TRANSPORT         default: "websockets"  (alternatives: "tcp")
#   MQTT_PORT              default: 9001 (WS) | 1883 (TCP)
#   MQTT_USERNAME          optional
#   MQTT_PASSWORD          optional
#   MQTT_COMMAND_TOPIC     default: "olink/commands"
#   MQTT_FEEDBACK_TOPIC    default: "olink/commands_feedback"
#     (legacy fallback: MQTT_RESPONSE_TOPIC if set)
#   MQTT_CONTEXT_TOPIC     default: "olink/context"
#   MQTT_QOS_SUB           default: 0
#   MQTT_QOS_PUB           default: 0
#   MQTT_KEEPALIVE         default: 60
#   MQTT_CLIENT_ID         optional client id
#   MQTT_INLINE_CODE_TOPIC default: "olink/inline_code"
#   MQTT_ENCODING          default: "utf-8"
#   MQTT_ENCODING_ERRORS   default: "replace"
#
# Command payloads accepted:
#   - Raw string: "move_white_knight_from_C2_to_C3"
#   - JSON:
#       {
#         "command": "move_white_knight_from_C2_to_C3",
#         "meta": {...},
#         "reply_to": "custom/topic"   # optional override for feedback
#       }
#
# Feedback publishing:
#   - Uses payload.reply_to OR meta.reply_to OR MQTT_FEEDBACK_TOPIC.
#   - Payload includes: {
#         "feedback": true|false,
#         "ok": true|false,
#         "template": str | null,
#         "normalized_template": str | null,
#         "errors": [...],
#         "handler_error"?: str,
#         "error"?: str,
#         "details"?: str,
#     }
#
# Context publishing:
#   - Only when give_context("<string>") is called.

try:
    import paho.mqtt.client as _mqtt  # type: ignore
except Exception:
    _mqtt = None  # optional until used

_FENCED_CODE_RX = re.compile(r"^```(?:[A-Za-z0-9#+\-_.]*)?\s*([\s\S]*?)\s*```$", re.DOTALL)


def _clean_inline_label(label: str) -> str:
    label = label.strip()
    if label.endswith(":"):
        label = label[:-1]
    return label or "Code"


def _prepare_inline_code(code: str) -> str:
    trimmed = code.strip()
    fenced = _FENCED_CODE_RX.match(trimmed)
    if fenced:
        trimmed = fenced.group(1).strip()
    elif trimmed.startswith("`") and trimmed.endswith("`") and len(trimmed) > 1:
        trimmed = trimmed[1:-1].strip()
    normalized = textwrap.dedent(trimmed.replace("\r\n", "\n")).strip("\n")
    return normalized


@dataclass
class InlineCodeSnippet:
    """Inline code snippet published alongside a command."""

    label: str
    content: str

    @classmethod
    def from_payload(cls, payload: Any) -> Optional["InlineCodeSnippet"]:
        if isinstance(payload, dict):
            raw_content = payload.get("content")
            raw_label = payload.get("label", "")
        else:
            raw_content = payload
            raw_label = ""

        if not isinstance(raw_content, str):
            return None

        content = _prepare_inline_code(raw_content)
        if not content:
            return None

        label = _clean_inline_label(str(raw_label))
        return cls(label=label, content=content)


@dataclass
class InlineCodeMessage:
    """Structured inline code payload ready to execute."""

    command: str
    snippets: List[InlineCodeSnippet]
    response: Optional[str] = None
    timestamp: Optional[str] = None

    @classmethod
    def from_payload(cls, payload: Any) -> Optional["InlineCodeMessage"]:
        if not isinstance(payload, dict):
            return None

        raw_snippets = payload.get("inlineCode")
        if not isinstance(raw_snippets, list):
            return None

        snippets: List[InlineCodeSnippet] = []
        for item in raw_snippets:
            snippet = InlineCodeSnippet.from_payload(item)
            if snippet:
                snippets.append(snippet)

        if not snippets:
            return None

        command = str(payload.get("command") or "").strip()
        response = payload.get("response")
        timestamp = payload.get("timestamp")

        return cls(
            command=command,
            snippets=snippets,
            response=str(response) if isinstance(response, str) and response else None,
            timestamp=str(timestamp) if isinstance(timestamp, str) and timestamp else None,
        )

    def to_script(self, *, comment_prefix: Optional[str] = "# ") -> str:
        """Combine all snippets into a single script ready for ``exec``.

        ``comment_prefix`` controls whether snippet labels are included as
        comments. Set it to ``None`` to omit labels entirely.
        """

        blocks: List[str] = []
        for snippet in self.snippets:
            code = snippet.content.strip("\n")
            if not code:
                continue
            label = snippet.label.strip()
            if comment_prefix is not None and label:
                blocks.append(f"{comment_prefix}{label}\n{code}")
            else:
                blocks.append(code)

        script = "\n\n".join(blocks).strip("\n")
        if script and not script.endswith("\n"):
            script += "\n"
        return script

    def to_payload(self) -> Dict[str, Any]:
        payload: Dict[str, Any] = {
            "command": self.command,
            "inlineCode": [
                {
                    "label": snippet.label,
                    "content": snippet.content,
                }
                for snippet in self.snippets
            ],
        }
        if self.response is not None:
            payload["response"] = self.response
        if self.timestamp is not None:
            payload["timestamp"] = self.timestamp
        return payload

# Global bridge singleton so give_context() can publish
_BRIDGE_SINGLETON: Optional["OmniLinkMQTTBridge"] = None


class _MosquittoAutoRunner:
    """Launch a local mosquitto broker on demand for development use."""

    def __init__(self, host: str, port: int, transport: str) -> None:
        self.host = host
        self.port = port
        self.transport = transport
        self.process: Optional[subprocess.Popen] = None
        self.config_path: Optional[str] = None

    def matches(self, host: str, port: int, transport: str) -> bool:
        return self.host == host and self.port == port and self.transport == transport

    def is_running(self) -> bool:
        return self.process is not None and self.process.poll() is None

    def start(self) -> bool:
        if self.is_running():
            return True

        mosquitto_path = shutil.which("mosquitto")
        if not mosquitto_path:
            LOGGER.info("Mosquitto executable not found; skipping broker auto-start")
            return False

        try:
            command = self._build_command(mosquitto_path)
        except OSError as exc:
            LOGGER.warning(
                "Mosquitto auto-start failed while creating config",
                extra={"error": str(exc)},
            )
            return False

        try:
            self.process = subprocess.Popen(
                command,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
        except OSError as exc:
            LOGGER.warning(
                "Mosquitto auto-start failed",
                extra={"error": str(exc)},
            )
            return False

        if not self._wait_for_port():
            LOGGER.warning("Mosquitto auto-start timed out waiting for broker")
            self.stop()
            return False

        LOGGER.info(
            "Mosquitto auto-started",
            extra={"host": self.host, "port": self.port, "transport": self.transport},
        )
        atexit.register(self.stop)
        return True

    def stop(self) -> None:
        if self.process is not None:
            if self.process.poll() is None:
                self.process.terminate()
                try:
                    self.process.wait(timeout=5)
                except subprocess.TimeoutExpired:
                    self.process.kill()
                    self.process.wait(timeout=5)
            self.process = None

        if self.config_path:
            try:
                os.remove(self.config_path)
            except OSError:
                pass
            self.config_path = None

    def _build_command(self, mosquitto_path: str) -> List[str]:
        config_lines = [f"listener {self.port} {self.host}"]
        if self.transport == "websockets":
            config_lines.append("protocol websockets")
        config_lines.append("allow_anonymous true")
        config_text = "\n".join(config_lines) + "\n"

        fd, path = tempfile.mkstemp(prefix="omnilink-mosquitto-", suffix=".conf")
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            handle.write(config_text)

        self.config_path = path
        return [mosquitto_path, "-c", path]

    def _wait_for_port(self, timeout: float = 5.0) -> bool:
        deadline = time.time() + timeout
        while time.time() < deadline:
            if self.process is not None and self.process.poll() is not None:
                return False
            if self._port_open():
                return True
            time.sleep(0.1)
        return False

    def _port_open(self) -> bool:
        try:
            with contextlib.closing(socket.create_connection((self.host, self.port), timeout=0.5)):
                return True
        except OSError:
            return False


_MOSQUITTO_RUNNER: Optional[_MosquittoAutoRunner] = None
_MOSQUITTO_LOCK = threading.Lock()


class _MQTTAgentMessenger(AgentMessenger):
    """Publish structured agent updates over MQTT topics."""

    def __init__(
        self,
        *,
        bridge: "OmniLinkMQTTBridge",
        client: "_mqtt.Client",
        base_topic: str,
        command: str,
    ) -> None:
        self._bridge = bridge
        self._client = client
        self._base_topic = base_topic.rstrip("/") or base_topic
        self._command = command

    def _topic(self, suffix: str) -> str:
        base = self._base_topic.rstrip("/") if self._base_topic else self._base_topic
        base = base or self._base_topic
        return f"{base}/{suffix}" if suffix else base

    def _publish(self, topic: str, payload: Dict[str, Any]) -> None:
        message = json.dumps(payload, default=str)
        self._client.publish(topic, message, qos=self._bridge.qos_pub)
        if self._bridge.log:
            LOGGER.info(
                "MQTT agent message",
                extra={"topic": topic, "command": self._command, "payload": payload},
            )

    def send_feedback(self, feedback: AgentFeedback) -> None:
        payload = feedback.to_payload(command=self._command)
        self._publish(self._topic("agent_feedback"), payload)
        legacy_payload = feedback.to_legacy_payload()
        legacy_payload = json.loads(json.dumps(legacy_payload, default=str))
        self._bridge._publish_feedback(self._client, self._base_topic, legacy_payload)

    def ask_question(self, question: AgentQuestion) -> PendingQuestion:
        pending = self._bridge.engine.create_question_waiter()
        payload = question.to_payload(command=self._command)
        payload.setdefault("data", {})
        if isinstance(payload["data"], dict):
            payload["data"].setdefault("correlationId", pending.correlation_id)
        else:
            payload["data"] = {"correlationId": pending.correlation_id}
        payload["correlationId"] = pending.correlation_id
        reply_base = self._topic("")
        self._bridge._ensure_reply_subscription(reply_base)
        self._publish(self._topic("agent_questions"), payload)
        fallback: Dict[str, Any] = {
            "feedback": True,
            "ok": True,
            "question": question.prompt,
        }
        if question.choices:
            fallback["choices"] = [str(choice) for choice in question.choices]
        if question.allow_multiple:
            fallback["allow_multiple"] = True
        if question.data:
            fallback["data"] = question.data
        fallback["correlationId"] = pending.correlation_id
        fallback = json.loads(json.dumps(fallback, default=str))
        self._bridge._publish_feedback(self._client, self._base_topic, fallback)
        return pending

    def acknowledge(
        self,
        status: str,
        *,
        message: Optional[str] = None,
        data: Optional[Dict[str, Any]] = None,
    ) -> None:
        payload: Dict[str, Any] = {"status": status, "command": self._command}
        if message is not None:
            payload["message"] = message
        if data:
            payload["data"] = data
        self._publish(self._topic("agent_ack"), payload)
        fallback: Dict[str, Any] = {"feedback": True, "ok": True, "status": status}
        if message is not None:
            fallback["message"] = message
        if data:
            fallback["data"] = data
        fallback = json.loads(json.dumps(fallback, default=str))
        self._bridge._publish_feedback(self._client, self._base_topic, fallback)


@dataclass
class _QueuedCommand:
    client: "_mqtt.Client"
    command: str
    meta: Dict[str, Any]
    messenger: Optional[AgentMessenger]
    out_topic: str


def _is_local_host(host: str) -> bool:
    host = host.strip().lower()
    if host in {"localhost", "127.0.0.1", "::1"}:
        return True
    try:
        resolved = socket.gethostbyname(host)
    except socket.gaierror:
        return False
    return resolved.startswith("127.") or resolved == "::1"


def _maybe_start_mosquitto(transport: str, host: str, port: int) -> None:
    if not _is_local_host(host):
        return

    if not _env_flag("MQTT_AUTOSTART", True):
        return

    global _MOSQUITTO_RUNNER

    with _MOSQUITTO_LOCK:
        runner = _MOSQUITTO_RUNNER
        if runner and runner.matches(host, port, transport):
            if runner.is_running():
                return
        runner = _MosquittoAutoRunner(host, port, transport)
        if runner.start():
            _MOSQUITTO_RUNNER = runner


class OmniLinkMQTTBridge:
    """MQTT <-> OmniLinkEngine bridge with structured feedback payloads."""

    def __init__(
        self,
        engine: OmniLinkEngine,
        host: Optional[str] = None,
        port: Optional[int] = None,
        command_topic: Optional[str] = None,
        response_topic: Optional[str] = None,  # kept for API compat; mapped to feedback
        username: Optional[str] = None,
        password: Optional[str] = None,
        transport: Optional[str] = None,
        keepalive: Optional[int] = None,
        client_id: Optional[str] = None,
        qos_sub: Optional[int] = None,
        qos_pub: Optional[int] = None,
        log: bool = True,
        *,
        context_topic: Optional[str] = None,
        state_topic: Optional[str] = None,
        inline_code_topic: Optional[str] = None,
        inline_code_handler: Optional[InlineCodeHandler] = None,
        message_encoding: Optional[str] = None,
        encoding_errors: Optional[str] = None,
    ) -> None:
        if _mqtt is None:
            raise RuntimeError("paho-mqtt is required. Install: pip install paho-mqtt")

        self.engine = engine
        self.log = log

        self.transport = (transport or os.environ.get("MQTT_TRANSPORT") or "websockets").lower()
        default_port = 9001 if self.transport == "websockets" else 1883
        self.host = host or os.environ.get("MQTT_HOST", "localhost")
        self.port = int(port if port is not None else int(os.environ.get("MQTT_PORT", str(default_port))))

        self.command_topic = command_topic or os.environ.get("MQTT_COMMAND_TOPIC", "olink/commands")

        # Feedback topic selection precedence:
        env_feedback = os.environ.get("MQTT_FEEDBACK_TOPIC")
        env_legacy = os.environ.get("MQTT_RESPONSE_TOPIC")  # legacy fallback
        self.response_topic = (
            response_topic
            or env_feedback
            or env_legacy
            or "olink/commands_feedback"
        )

        # Context topic
        self.context_topic = context_topic or os.environ.get("MQTT_CONTEXT_TOPIC", "olink/context")
        self.state_topic = state_topic or os.environ.get("MQTT_STATE_TOPIC", "olink/state")

        inline_env = os.environ.get("MQTT_INLINE_CODE_TOPIC", "olink/inline_code")
        chosen_inline_topic = inline_code_topic if inline_code_topic is not None else inline_env
        self.inline_code_topic = chosen_inline_topic.strip() if chosen_inline_topic else ""
        self.inline_code_handler = inline_code_handler

        self.username = username or os.environ.get("MQTT_USERNAME")
        self.password = password or os.environ.get("MQTT_PASSWORD")
        self.keepalive = int(keepalive if keepalive is not None else int(os.environ.get("MQTT_KEEPALIVE", "60")))
        self.qos_sub = int(qos_sub if qos_sub is not None else int(os.environ.get("MQTT_QOS_SUB", "0")))
        self.qos_pub = int(qos_pub if qos_pub is not None else int(os.environ.get("MQTT_QOS_PUB", "0")))
        default_encoding = os.environ.get("MQTT_ENCODING", "utf-8")
        self.message_encoding = message_encoding or default_encoding
        self.encoding_errors = encoding_errors or os.environ.get("MQTT_ENCODING_ERRORS", "replace")
        self.client = _mqtt.Client(transport=self.transport, client_id=(client_id or os.environ.get("MQTT_CLIENT_ID")))
        if self.username:
            self.client.username_pw_set(self.username, self.password)

        # callbacks
        self.client.on_connect = self._on_connect
        self.client.on_message = self._on_message

        self._reply_topics: Set[str] = set()
        self._reply_lock = threading.Lock()
        self._command_queue: "queue.Queue[_QueuedCommand]" = queue.Queue()
        self._command_thread = threading.Thread(
            target=self._command_worker,
            name="OmniLinkMQTTBridgeWorker",
            daemon=True,
        )
        self._command_thread.start()

        # register singleton
        global _BRIDGE_SINGLETON
        _BRIDGE_SINGLETON = self

        try:
            self._engine_accepts_messenger = "messenger" in signature(self.engine.handle).parameters
        except (ValueError, TypeError, AttributeError):
            self._engine_accepts_messenger = False

    # ----- Context publishing helper (used by give_context)
    def publish_context(self, context_str: str) -> None:
        """Publish the provided context string to the context topic."""
        try:
            payload = json.dumps({"context": str(context_str)})
            self.client.publish(self.context_topic, payload, qos=self.qos_pub)
            if self.log:
                LOGGER.info(
                    "MQTT context published",
                    extra={"topic": self.context_topic, "payload": payload},
                )
        except Exception as e:
            LOGGER.exception("Context publish error", extra={"topic": self.context_topic})

    def publish_state_snapshot(
        self,
        *,
        history_limit: Optional[int] = None,
        include_metrics: bool = True,
        include_history: bool = True,
        include_messenger: bool = False,
    ) -> Dict[str, Any]:
        """Publish a serialized engine context snapshot to the state topic."""

        snapshot = self.engine.get_context(
            history_limit=history_limit,
            include_metrics=include_metrics,
            include_history=include_history,
            include_messenger=include_messenger,
        )
        safe_snapshot = _json_safe_copy(snapshot)
        payload = json.dumps(safe_snapshot)
        self.client.publish(self.state_topic, payload, qos=self.qos_pub)
        if self.log:
            LOGGER.info(
                "MQTT state snapshot published",
                extra={"topic": self.state_topic, "history_limit": history_limit},
            )
        return safe_snapshot

    def _on_connect(self, client: "_mqtt.Client", _ud, _flags, rc: int):
        if rc == 0:
            if self.log:
                LOGGER.info(
                    "MQTT connected",
                    extra={"host": self.host, "port": self.port, "transport": self.transport},
                )
            client.subscribe(self.command_topic, qos=self.qos_sub)
            if self.log:
                LOGGER.info(
                    "MQTT subscribed", extra={"topic": self.command_topic, "qos": self.qos_sub}
                )
            if self.inline_code_topic:
                client.subscribe(self.inline_code_topic, qos=self.qos_sub)
                if self.log:
                    LOGGER.info(
                        "MQTT subscribed",
                        extra={"topic": self.inline_code_topic, "qos": self.qos_sub, "kind": "inline"},
                    )
            with self._reply_lock:
                reply_topics = list(self._reply_topics)
            for topic in reply_topics:
                client.subscribe(topic, qos=self.qos_sub)
                if self.log:
                    LOGGER.info(
                        "MQTT subscribed",
                        extra={"topic": topic, "qos": self.qos_sub, "kind": "agent_replies"},
                    )
            # NOTE: No automatic context publishing here.
        else:
            LOGGER.error("MQTT connect failed", extra={"rc": rc})

    def _on_message(self, client: "_mqtt.Client", _ud, msg: "_mqtt.MQTTMessage"):
        # Accept raw or JSON
        try:
            payload_text = msg.payload.decode(self.message_encoding, self.encoding_errors)
            data = None
            try:
                data = json.loads(payload_text)
            except json.JSONDecodeError:
                pass
        except Exception as exc:
            LOGGER.exception("MQTT decode error", extra={"topic": msg.topic})
            error_payload = {
                "feedback": False,
                "ok": False,
                "error": "decode_error",
                "details": str(exc),
            }
            client.publish(
                self._resolve_reply_to(None),
                json.dumps(error_payload),
                qos=self.qos_pub,
            )
            return

        if msg.topic == self.command_topic:
            self._handle_command_message(client, msg, payload_text, data, _async=True)
            return

        if self.inline_code_topic and msg.topic == self.inline_code_topic:
            self._handle_inline_code_message(payload_text, data)
            return

        with self._reply_lock:
            reply_topics = set(self._reply_topics)
        if msg.topic in reply_topics:
            self._handle_reply_message(payload_text, data, msg.topic)
            return

        if self.log:
            LOGGER.info("MQTT ignoring message", extra={"topic": msg.topic})

    def _resolve_reply_to(self, reply_to: Optional[str]) -> str:
        return reply_to or self.response_topic

    def _handle_control_message(
        self,
        control: str,
        client: "_mqtt.Client",
        reply_to: Optional[str],
        payload: Dict[str, Any],
        _meta: Dict[str, Any],
    ) -> bool:
        normalized = control.strip().lower()
        if normalized != "state.request":
            return False

        options = payload.get("options") if isinstance(payload, dict) else None
        option_map: Dict[str, Any] = options if isinstance(options, dict) else {}

        def _get_option(name: str, default: Any) -> Any:
            if name in option_map:
                return option_map[name]
            return payload.get(name, default)

        history_limit_raw = _get_option("history_limit", None)
        if isinstance(history_limit_raw, (int, float)):
            history_limit = int(history_limit_raw)
        else:
            history_limit = None

        include_metrics_raw = _get_option("include_metrics", True)
        include_metrics = include_metrics_raw if isinstance(include_metrics_raw, bool) else True

        include_history_raw = _get_option("include_history", True)
        include_history = include_history_raw if isinstance(include_history_raw, bool) else True

        include_messenger_raw = _get_option("include_messenger", False)
        include_messenger = (
            include_messenger_raw if isinstance(include_messenger_raw, bool) else False
        )

        snapshot = self.publish_state_snapshot(
            history_limit=history_limit,
            include_metrics=include_metrics,
            include_history=include_history,
            include_messenger=include_messenger,
        )

        if self.log:
            LOGGER.info(
                "MQTT state request handled",
                extra={
                    "topic": self.state_topic,
                    "history_limit": history_limit,
                    "include_metrics": include_metrics,
                    "include_history": include_history,
                },
            )

        ack_payload: Dict[str, Any] = {
            "feedback": True,
            "ok": True,
            "control": control,
            "state_topic": self.state_topic,
            "generated_at": snapshot.get("generated_at"),
        }
        if history_limit is not None:
            ack_payload["history_limit"] = history_limit
        if not include_history:
            ack_payload["include_history"] = False
        if not include_metrics:
            ack_payload["include_metrics"] = False

        self._publish_feedback(client, self._resolve_reply_to(reply_to), ack_payload)
        return True

    def _create_messenger(
        self,
        client: "_mqtt.Client",
        reply_to: Optional[str],
        command: str,
    ) -> Optional[AgentMessenger]:
        if not self._engine_accepts_messenger:
            return None
        base_topic = self._resolve_reply_to(reply_to)
        return _MQTTAgentMessenger(
            bridge=self,
            client=client,
            base_topic=base_topic,
            command=command,
        )

    def _ensure_reply_subscription(self, base_topic: str) -> None:
        base = base_topic.rstrip("/")
        if not base:
            return
        reply_topic = f"{base}/agent_replies"
        with self._reply_lock:
            if reply_topic in self._reply_topics:
                return
            self._reply_topics.add(reply_topic)
        self.client.subscribe(reply_topic, qos=self.qos_sub)
        if self.log:
            LOGGER.info(
                "MQTT subscribed",
                extra={"topic": reply_topic, "qos": self.qos_sub, "kind": "agent_replies"},
            )

    def _command_worker(self) -> None:
        while True:
            queued = self._command_queue.get()
            try:
                if queued is None:
                    return
                self._process_command(queued)
            except Exception:
                LOGGER.exception("MQTT command worker error")
            finally:
                self._command_queue.task_done()

    def _process_command(self, queued: _QueuedCommand) -> None:
        feedback_payload: Dict[str, Any]
        try:
            if queued.messenger is not None:
                res = self.engine.handle(
                    queued.command,
                    meta=queued.meta,
                    messenger=queued.messenger,
                )
            else:
                res = self.engine.handle(queued.command, meta=queued.meta)
            result = res.get("result")
            handler_failed = isinstance(result, dict) and bool(result.get("error"))
            feedback_payload = {
                "feedback": bool(res.get("ok")) and not handler_failed,
                "ok": bool(res.get("ok")),
                "template": res.get("template"),
                "normalized_template": res.get("normalized_template"),
                "errors": res.get("errors", []),
            }
            if handler_failed:
                feedback_payload["handler_error"] = result.get("error")
        except Exception as exc:
            LOGGER.exception("MQTT handler exception", extra={"command": queued.command})
            feedback_payload = {
                "feedback": False,
                "ok": False,
                "error": "handler_exception",
                "details": str(exc),
            }

        self._publish_feedback(queued.client, queued.out_topic, feedback_payload)

    def _publish_feedback(self, client: "_mqtt.Client", topic: str, payload: Dict[str, Any]) -> None:
        client.publish(topic, json.dumps(payload), qos=self.qos_pub)
        if self.log:
            LOGGER.info("MQTT feedback", extra={"topic": topic, **payload})

    def _handle_reply_message(self, payload_text: str, data: Any, topic: str) -> None:
        if isinstance(data, dict):
            payload = data
        else:
            try:
                payload = json.loads(payload_text)
            except Exception:
                payload = None
        if not isinstance(payload, dict):
            if self.log:
                LOGGER.info(
                    "Ignoring agent reply payload", extra={"topic": topic, "reason": "not a dict"}
                )
            return
        try:
            handled = self.engine.receive_agent_reply(payload)
        except Exception:
            handled = False
            LOGGER.exception("Agent reply dispatch error", extra={"topic": topic})
        else:
            if not handled and self.log:
                LOGGER.info(
                    "Agent reply without matching question",
                    extra={
                        "topic": topic,
                        "correlationId": payload.get("correlationId") or payload.get("correlation_id"),
                    },
                )

    def _handle_command_message(
        self,
        client: "_mqtt.Client",
        msg: "_mqtt.MQTTMessage",
        payload_text: str,
        data: Any,
        *,
        _async: bool = False,
    ) -> None:
        if isinstance(data, dict):
            command = data.get("command")
            raw_meta = data.get("meta")
            meta: Dict[str, Any]
            if isinstance(raw_meta, dict):
                meta = dict(raw_meta)
            else:
                meta = {}
                if raw_meta is not None and self.log:
                    LOGGER.info("Ignoring non-dict meta payload", extra={"type": type(raw_meta).__name__})

            extra_agent_meta: Dict[str, Any] = {}
            reserved_keys = {"command", "meta", "reply_to"}
            for key, value in data.items():
                if key not in reserved_keys:
                    extra_agent_meta[key] = value

            if extra_agent_meta:
                existing_agent_meta = meta.get("agent")
                if isinstance(existing_agent_meta, dict):
                    agent_meta = dict(existing_agent_meta)
                else:
                    agent_meta = {}
                    if existing_agent_meta is not None and self.log:
                        LOGGER.info(
                            "Ignoring non-dict agent meta", extra={"type": type(existing_agent_meta).__name__}
                        )
                agent_meta.update(extra_agent_meta)
                meta["agent"] = agent_meta

            reply_to = data.get("reply_to") or meta.get("reply_to")
            control = data.get("control")
            if isinstance(control, str) and control.strip():
                if self._handle_control_message(control, client, reply_to, data, meta):
                    return
        else:
            command = payload_text
            meta = {}
            reply_to = None

        if self.log:
            LOGGER.info("MQTT received command", extra={"topic": msg.topic, "command": command})

        if not isinstance(command, str) or not command.strip():
            payload = {
                "feedback": False,
                "ok": False,
                "error": "empty_command",
            }
            self._publish_feedback(client, self._resolve_reply_to(reply_to), payload)
            return

        messenger = self._create_messenger(client, reply_to, command)
        out_topic = self._resolve_reply_to(reply_to)
        queued = _QueuedCommand(
            client=client,
            command=command,
            meta=dict(meta),
            messenger=messenger,
            out_topic=out_topic,
        )
        if _async:
            self._command_queue.put(queued)
        else:
            self._process_command(queued)

    def _handle_inline_code_message(self, payload_text: str, data: Any) -> None:
        message: Optional[InlineCodeMessage]
        if isinstance(data, dict):
            message = InlineCodeMessage.from_payload(data)
        else:
            try:
                message = InlineCodeMessage.from_payload(json.loads(payload_text))
            except Exception:
                message = None

        if not message:
            if self.log:
                LOGGER.info("Ignoring inline code payload without snippets")
            return

        if self.log:
            LOGGER.info(
                "MQTT inline code received",
                extra={"command": message.command, "snippet_count": len(message.snippets)},
            )

        if self.inline_code_handler is None:
            return

        try:
            self.inline_code_handler(message)
        except Exception as exc:
            LOGGER.exception("Inline code handler error", extra={"command": message.command})

    # lifecycle
    def start(self) -> "OmniLinkMQTTBridge":
        _maybe_start_mosquitto(self.transport, self.host, self.port)
        self.client.connect(self.host, self.port, keepalive=self.keepalive)
        self.client.loop_start()
        if self.log:
            LOGGER.info(
                "MQTT listening",
                extra={"host": self.host, "port": self.port, "subscription": self.command_topic},
            )
        return self

    def loop_forever(self) -> None:
        _maybe_start_mosquitto(self.transport, self.host, self.port)
        self.client.connect(self.host, self.port, keepalive=self.keepalive)
        if self.log:
            LOGGER.info("MQTT listening (blocking loop)")
        try:
            self.client.loop_forever()
        except KeyboardInterrupt:
            pass

# =========================================================
# Public API: give_context
# =========================================================

def give_context(context_str: str) -> None:
    """
    Publish a context string to MQTT_CONTEXT_TOPIC (default 'olink/context').
    Requires that an OmniLinkMQTTBridge has been instantiated (sets a module singleton).
    """
    global _BRIDGE_SINGLETON
    if _BRIDGE_SINGLETON is None:
        raise RuntimeError("OmniLinkMQTTBridge is not initialized; create the bridge before calling give_context().")
    _BRIDGE_SINGLETON.publish_context(context_str)

# =========================================================
# Periodic context publishing (integrated)
# =========================================================
_CONTEXT_THREAD: Optional[threading.Thread] = None
_CONTEXT_STOP: Optional[threading.Event] = None

def start_periodic_context(interval_seconds: float, message: Union[str, Callable[[], str]]) -> None:
    """
    Start a background thread that calls give_context(...) every `interval_seconds`.
    `message` can be either a string or a zero-arg callable returning a string.
    If called again, it will restart with the new settings.
    """
    global _CONTEXT_THREAD, _CONTEXT_STOP, _BRIDGE_SINGLETON
    if _BRIDGE_SINGLETON is None:
        raise RuntimeError("OmniLinkMQTTBridge is not initialized; create the bridge before start_periodic_context().")

    # If already running, stop the old one first
    stop_periodic_context()

    stop_evt = threading.Event()
    _CONTEXT_STOP = stop_evt

    def _producer() -> str:
        return message() if callable(message) else str(message)

    def _loop():
        # first immediate push
        try:
            give_context(_producer())
        except Exception as e:
            LOGGER.exception("Periodic context initial publish error")

        # then periodic
        while not stop_evt.wait(interval_seconds):
            try:
                give_context(_producer())
            except Exception as e:
                LOGGER.exception("Periodic context publish error")

    t = threading.Thread(target=_loop, daemon=True)
    _CONTEXT_THREAD = t
    t.start()
    if _BRIDGE_SINGLETON and _BRIDGE_SINGLETON.log:
        LOGGER.info(
            "Periodic context started",
            extra={"interval_seconds": interval_seconds},
        )

def stop_periodic_context() -> None:
    """Stop the periodic context thread if running."""
    global _CONTEXT_THREAD, _CONTEXT_STOP
    if _CONTEXT_STOP is not None:
        _CONTEXT_STOP.set()
        _CONTEXT_STOP = None
    _CONTEXT_THREAD = None


# =========================================================
# Chat API client
# =========================================================
# HTTP Bridge
# =========================================================


class _CommandState:
    """Per-request state container for OmniLinkHTTPBridge."""

    __slots__ = (
        "request_id", "command", "feedback", "questions", "acks",
        "result", "done", "error", "_lock",
    )

    def __init__(self, request_id: str, command: str) -> None:
        self.request_id = request_id
        self.command = command
        self.feedback: List[Dict[str, Any]] = []
        self.questions: List[Dict[str, Any]] = []
        self.acks: List[Dict[str, Any]] = []
        self.result: Optional[Dict[str, Any]] = None
        self.done: bool = False
        self.error: Optional[str] = None
        self._lock = threading.Lock()


class _HTTPAgentMessenger:
    """Collect structured agent updates into a _CommandState for HTTP responses."""

    def __init__(self, bridge: "OmniLinkHTTPBridge", state: _CommandState) -> None:
        self._bridge = bridge
        self._state = state

    def send_feedback(self, feedback: AgentFeedback) -> None:
        payload = feedback.to_payload(command=self._state.command)
        with self._state._lock:
            self._state.feedback.append(payload)
        if self._bridge.log:
            LOGGER.info(
                "HTTP agent feedback",
                extra={"request_id": self._state.request_id, "message": feedback.message},
            )

    def ask_question(self, question: AgentQuestion) -> PendingQuestion:
        pending = self._bridge.engine.create_question_waiter()
        payload = question.to_payload(command=self._state.command)
        payload["correlationId"] = pending.correlation_id
        payload.setdefault("data", {})
        if isinstance(payload["data"], dict):
            payload["data"]["correlationId"] = pending.correlation_id
        with self._state._lock:
            self._state.questions.append(payload)
        if self._bridge.log:
            LOGGER.info(
                "HTTP agent question",
                extra={
                    "request_id": self._state.request_id,
                    "correlationId": pending.correlation_id,
                },
            )
        return pending

    def acknowledge(
        self,
        status: str,
        *,
        message: Optional[str] = None,
        data: Optional[Dict[str, Any]] = None,
    ) -> None:
        payload: Dict[str, Any] = {"status": status, "command": self._state.command}
        if message is not None:
            payload["message"] = message
        if data:
            payload["data"] = data
        with self._state._lock:
            self._state.acks.append(payload)


class _OmniLinkHTTPHandler(_http_server.BaseHTTPRequestHandler):
    """Internal HTTP request handler for OmniLinkHTTPBridge."""

    bridge: "OmniLinkHTTPBridge"

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

    def _send_json(self, data: Dict[str, Any], status: int = 200) -> None:
        body = json.dumps(data, default=str).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _read_json(self) -> Optional[Dict[str, Any]]:
        length = int(self.headers.get("Content-Length") or 0)
        if not length:
            return {}
        raw = self.rfile.read(length)
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            return None

    # ------------------------------------------------------------------
    # Routing
    # ------------------------------------------------------------------

    def do_POST(self) -> None:
        path = self.path.split("?")[0].rstrip("/") or "/"
        if path == "/command":
            self._handle_command()
        elif path == "/reply":
            self._handle_reply()
        else:
            self._send_json({"error": "Not found"}, 404)

    def do_GET(self) -> None:
        path = self.path.split("?")[0].rstrip("/") or "/"
        if path == "/context":
            self._handle_context()
        elif path == "/state":
            self._handle_state()
        elif path.startswith("/feedback/"):
            self._handle_feedback(path[len("/feedback/"):])
        elif path.startswith("/result/"):
            self._handle_result(path[len("/result/"):])
        else:
            self._send_json({"error": "Not found"}, 404)

    # ------------------------------------------------------------------
    # Handlers
    # ------------------------------------------------------------------

    def _handle_command(self) -> None:
        body = self._read_json()
        if body is None:
            self._send_json({"error": "Invalid JSON"}, 400)
            return
        command = (body.get("command") or "").strip()
        if not command:
            self._send_json({"error": "Missing 'command'"}, 400)
            return
        meta = body.get("meta") or {}
        request_id = str(uuid.uuid4())
        state = _CommandState(request_id=request_id, command=command)
        self.bridge._register_state(request_id, state)
        self.bridge._enqueue(command, meta, state)
        self._send_json({"requestId": request_id, "status": "accepted"}, 202)

    def _handle_reply(self) -> None:
        body = self._read_json()
        if body is None:
            self._send_json({"error": "Invalid JSON"}, 400)
            return
        try:
            delivered = self.bridge.engine.receive_agent_reply(body)
            self._send_json({"ok": True, "delivered": bool(delivered)})
        except Exception as exc:
            self._send_json({"ok": False, "error": str(exc)}, 500)

    def _handle_feedback(self, request_id: str) -> None:
        state = self.bridge._get_state(request_id)
        if state is None:
            self._send_json({"error": "Unknown requestId"}, 404)
            return
        with state._lock:
            self._send_json({
                "requestId": request_id,
                "command": state.command,
                "done": state.done,
                "acks": list(state.acks),
                "feedback": list(state.feedback),
                "questions": list(state.questions),
            })

    def _handle_result(self, request_id: str) -> None:
        state = self.bridge._get_state(request_id)
        if state is None:
            self._send_json({"error": "Unknown requestId"}, 404)
            return
        with state._lock:
            if not state.done:
                self._send_json({"requestId": request_id, "done": False})
            elif state.error:
                self._send_json({
                    "requestId": request_id,
                    "done": True,
                    "ok": False,
                    "error": state.error,
                })
            else:
                self._send_json({
                    "requestId": request_id,
                    "done": True,
                    "ok": True,
                    "result": state.result,
                })

    def _handle_context(self) -> None:
        self._send_json(_json_safe_copy(self.bridge.engine.get_context()))

    def _handle_state(self) -> None:
        self._send_json(_json_safe_copy(self.bridge.engine.get_context()))

    def log_message(self, fmt: str, *args: Any) -> None:  # type: ignore[override]
        if self.bridge.log:
            LOGGER.info("HTTP %s", fmt % args)


class OmniLinkHTTPBridge:
    """HTTP server bridge that exposes an OmniLinkEngine over REST endpoints.

    Commands are submitted as HTTP requests and processed asynchronously by
    the same :class:`OmniLinkEngine` used by the MQTT bridge — making HTTP a
    first-class connection method alongside MQTT.

    Endpoints
    ---------
    ``POST /command``
        Submit a command.
        Body: ``{"command": "...", "meta": {...}}``.
        Returns ``{"requestId": "...", "status": "accepted"}`` (202).

    ``POST /reply``
        Answer a pending agent question.
        Body: ``{"correlationId": "...", "answer": "..."}``.
        Returns ``{"ok": True}``.

    ``GET /feedback/{requestId}``
        Poll for accumulated acknowledgements, feedback messages, and
        questions emitted by the handler so far.

    ``GET /result/{requestId}``
        Poll for the final handler result.
        Returns ``{"done": false}`` while processing is still in progress.

    ``GET /context``
        Return the engine metrics and recent command history.

    ``GET /state``
        Alias for ``/context``.

    Parameters
    ----------
    engine:
        The :class:`OmniLinkEngine` to route commands through.
    host:
        Bind address (default: ``"0.0.0.0"``; env: ``HTTP_BRIDGE_HOST``).
    port:
        Bind port (default: ``8080``; env: ``HTTP_BRIDGE_PORT``).
    log:
        Whether to emit log messages (default: ``True``).

    Example
    -------
    ::

        from omnilink import OmniLinkEngine, OmniLinkHTTPBridge

        engine = OmniLinkEngine(["hello [name]"])
        engine.on(lambda e: True, lambda e: {"greeting": f"Hello, {e['vars']['name']}!"})

        bridge = OmniLinkHTTPBridge(engine, port=8080)
        bridge.loop_forever()
        # POST http://localhost:8080/command  {"command": "hello World"}
    """

    def __init__(
        self,
        engine: OmniLinkEngine,
        host: Optional[str] = None,
        port: Optional[int] = None,
        log: bool = True,
    ) -> None:
        self.engine = engine
        self.log = log
        self.host = host or os.environ.get("HTTP_BRIDGE_HOST", "0.0.0.0")
        self.port = int(
            port if port is not None
            else int(os.environ.get("HTTP_BRIDGE_PORT", "8080"))
        )

        self._states: Dict[str, _CommandState] = {}
        self._states_lock = threading.Lock()
        self._command_queue: "queue.Queue[Tuple[str, Dict[str, Any], _CommandState]]" = queue.Queue()
        self._stop_event = threading.Event()
        self._server: Optional[_http_server.HTTPServer] = None
        self._server_thread: Optional[threading.Thread] = None

        # Build a per-instance handler subclass that holds a reference to this bridge.
        bridge = self

        class _Handler(_OmniLinkHTTPHandler):
            pass

        _Handler.bridge = bridge  # type: ignore[attr-defined]
        self._handler_class = _Handler

        # Start command worker thread.
        self._worker_thread = threading.Thread(
            target=self._command_worker,
            name="OmniLinkHTTPBridgeWorker",
            daemon=True,
        )
        self._worker_thread.start()

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _register_state(self, request_id: str, state: _CommandState) -> None:
        with self._states_lock:
            self._states[request_id] = state

    def _get_state(self, request_id: str) -> Optional[_CommandState]:
        with self._states_lock:
            return self._states.get(request_id)

    def _enqueue(self, command: str, meta: Dict[str, Any], state: _CommandState) -> None:
        self._command_queue.put((command, meta, state))

    def _command_worker(self) -> None:
        while not self._stop_event.is_set():
            try:
                item = self._command_queue.get(timeout=0.5)
            except queue.Empty:
                continue
            command, meta, state = item
            messenger = _HTTPAgentMessenger(self, state)
            try:
                result = self.engine.handle(command, meta=meta, messenger=messenger)
                with state._lock:
                    state.result = _json_safe_copy(result)
                    state.done = True
            except Exception as exc:
                LOGGER.exception("HTTP bridge command error", extra={"command": command})
                with state._lock:
                    state.error = str(exc)
                    state.done = True
            finally:
                self._command_queue.task_done()
            if self.log:
                LOGGER.info(
                    "HTTP command handled",
                    extra={"command": command, "request_id": state.request_id},
                )

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

    def loop_forever(self) -> None:
        """Start the HTTP server and block until interrupted."""
        server = _http_server.ThreadingHTTPServer(
            (self.host, self.port), self._handler_class
        )
        self._server = server
        self._stop_event.clear()
        if self.log:
            LOGGER.info(
                "HTTP bridge listening",
                extra={"host": self.host, "port": self.port},
            )
        try:
            server.serve_forever()
        except KeyboardInterrupt:
            pass
        finally:
            server.server_close()
            self._server = None

    def start(self) -> "OmniLinkHTTPBridge":
        """Start the HTTP server in a background thread."""
        if self._server_thread and self._server_thread.is_alive():
            return self
        self._stop_event.clear()
        thread = threading.Thread(
            target=self.loop_forever,
            name="OmniLinkHTTPBridgeServer",
            daemon=True,
        )
        self._server_thread = thread
        thread.start()
        if self.log:
            LOGGER.info(
                "HTTP bridge started in background",
                extra={"host": self.host, "port": self.port},
            )
        return self

    def stop(self) -> None:
        """Stop the HTTP server and worker thread."""
        self._stop_event.set()
        if self._server is not None:
            self._server.shutdown()
            self._server = None
        if self._server_thread and self._server_thread.is_alive():
            self._server_thread.join(timeout=5)
        self._server_thread = None


# =========================================================

class OmniLinkChatClient:
    """HTTP client for the OmniLink ``/api/chat`` endpoint.

    Parameters
    ----------
    base_url:
        Root URL of your OmniLink deployment, e.g. ``"https://your-app.vercel.app"``.
        No trailing slash needed.
    omni_key:
        Bearer token (Omni Key) used to authenticate requests.
    timeout:
        HTTP request timeout in seconds (default: 30).

    Example
    -------
    ::

        from omnilink import OmniLinkChatClient

        client = OmniLinkChatClient(
            base_url="https://www.omnilink-agents.com",
            omni_key="omk_...",
        )

        response = client.chat(
            prompt="What is the status of mission Alpha?",
            agent_name="mission-control",
        )
        print(response["text"])
    """

    def __init__(
        self,
        base_url: str,
        omni_key: str,
        *,
        timeout: int = 30,
    ) -> None:
        if requests is None:
            raise ImportError(
                "The 'requests' package is required for OmniLinkChatClient. "
                "Install it with: pip install requests"
            )
        self._base_url = base_url.rstrip("/")
        self._omni_key = omni_key
        self._timeout = timeout

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def chat(
        self,
        prompt: Optional[str] = None,
        *,
        agent_name: str,
        messages: Optional[List[Dict[str, Any]]] = None,
        engine: str = "g2-engine",
        temperature: Optional[float] = None,
        max_tokens: Optional[int] = None,
        system_instruction_request: Optional[Dict[str, Any]] = None,
        system_instruction_suffix: Optional[str] = None,
        use_prompt_pipeline: bool = False,
        **extra: Any,
    ) -> Dict[str, Any]:
        """Send a chat request to ``/api/chat`` and return the parsed response.

        Either ``prompt`` (a plain string) or ``messages`` (a list of
        ``{"role": ..., "content": ...}`` dicts) must be provided.

        Parameters
        ----------
        prompt:
            Shorthand for a single user message. Mutually exclusive with
            ``messages``.
        agent_name:
            Name of the target agent. Required by the server.
        messages:
            Full conversation array in OpenAI format.  Takes precedence
            over ``prompt`` when both are given.
        engine:
            Which AI engine to use: ``"g1-engine"`` (Gemini),
            ``"g2-engine"`` (GPT-5, default), ``"g3-engine"`` (Grok),
            or ``"g4-engine"`` (Claude).
        temperature:
            Optional sampling temperature forwarded to the engine.
        max_tokens:
            Optional token limit forwarded to the engine.
        system_instruction_request:
            Structured system-instruction object forwarded to the engine.
        system_instruction_suffix:
            Additional text appended to the system instruction.
        use_prompt_pipeline:
            When ``True`` the server composes system instructions
            automatically from agent context.
        **extra:
            Any additional fields to merge into the request body (e.g.
            ``agentPersona``, ``customInstructions``, ``knowledgeMode``).

        Returns
        -------
        dict
            Parsed JSON response. On success contains at minimum
            ``{"ok": True, "text": "...", "requestId": "..."}``.

        Raises
        ------
        requests.RequestException
            On network or HTTP-level failures.
        RuntimeError
            When the server returns a non-2xx status code.
        """
        body: Dict[str, Any] = {
            "agentName": agent_name,
            "engine": engine,
            "usePromptPipeline": use_prompt_pipeline,
        }

        if messages is not None:
            body["messages"] = messages
        elif prompt is not None:
            body["prompt"] = prompt
        else:
            raise ValueError("Either 'prompt' or 'messages' must be provided.")

        if temperature is not None:
            body["temperature"] = temperature
        if max_tokens is not None:
            body["max_tokens"] = max_tokens
        if system_instruction_request is not None:
            body["systemInstructionRequest"] = system_instruction_request
        if system_instruction_suffix is not None:
            body["systemInstructionSuffix"] = system_instruction_suffix

        body.update(extra)

        url = f"{self._base_url}/api/chat"
        headers = {
            "Authorization": f"Bearer {self._omni_key}",
            "Content-Type": "application/json",
        }

        response = requests.post(url, json=body, headers=headers, timeout=self._timeout)

        if not response.ok:
            try:
                error_body = response.json()
            except Exception:
                error_body = {"raw": response.text}
            raise RuntimeError(
                f"OmniLink chat request failed [{response.status_code}]: {error_body}"
            )

        return response.json()


# =========================================================
# Example (commented)
# =========================================================
# if __name__ == "__main__":
#     types = TypeRegistry()
#     patterns = [
#         "move_[color]_[piece]_from_[location1]_to_[location2]",
#         "say [text:any]",
#     ]
#     engine = OmniLinkEngine(patterns, types=types)
#
#     def handle_any(evt):
#         print("EVENT VARS:", evt["vars"])
#         # return {"error": "something"}  # would cause feedback:false
#         return {"ack": True}
#     engine.on(lambda e: True, handle_any)
#
#     bridge = OmniLinkMQTTBridge(engine).start()
#     # Example usage:
#     # give_context("context: system ready")
#     bridge.loop_forever()
