"""CompanionManager — lifecycle, signal routing, and event dispatch."""
from __future__ import annotations

import json
import logging
import os
from typing import Any

from aura.version import __version__

from PySide6.QtCore import QObject, Signal

from aura.companion.auth import (
    create_device_token,
    generate_pairing_code,
    generate_ticket,
    get_device_display_name,
    get_device_id,
    invalidate_pairing_code,
    pairing_code_expiry,
    validate_pairing_code,
)
from aura.companion.client import CompanionWsClient
from pathlib import Path

from aura.companion.protocol import (
    ActiveRunSummary,
    CompanionProject,
    CompanionThread,
    ReceiptSummary,
    make_envelope,
)
from aura.drones.store import RunHistoryStore
from aura.conversation.persistence import load_conversation
from aura.projects.store import ProjectStore
from aura.settings import AppSettings, resolve_role_default_model

logger = logging.getLogger(__name__)


class CompanionManager(QObject):
    """Manages the Companion (mobile web control plane) connection lifecycle.

    Owns the WebSocket client, routes incoming commands, and forwards
    desktop events to the phone via Relay.
    """

    connection_status_changed = Signal(str)  # "disabled", "connecting", "connected", "error"
    connection_error = Signal(str)           # exact error string from the ws worker
    message_received = Signal(dict)
    pairing_code_available = Signal(str)       # Emitted when a pairing code is generated
    pairing_code_invalidated = Signal()        # Emitted when a pairing code is cancelled
    pairing_complete = Signal(str)             # Emitted when a phone pairs successfully (param: device_name)
    conversation_selected_by_companion = Signal(Path, Path)  # project_root_path, conversation_path

    def __init__(self, settings: AppSettings | None = None, parent: QObject | None = None) -> None:
        super().__init__(parent)
        self._settings = settings or AppSettings()
        self._ws_client: CompanionWsClient | None = None
        self._bridge: Any = None
        self._drone_runner: Any = None
        self._project_store: Any = None
        self._workspace_root: str = ""
        self._current_project_id: str = ""
        self._current_conversation_id: str = ""
        self._conversation_loaded: bool = False
        self._pending_select_msg: dict | None = None
        self._pending_chat_id: str = ""
        self._pending_chat_phone_id: str = ""
        self._current_pairing_code: str = ""
        self._paired_context: dict = {}
        self._paired_project_name: str = ""

    # ── Lifecycle ────────────────────────────────────────────

    def start(self) -> None:
        if not self._settings.companion_enabled:
            self.connection_status_changed.emit("disabled")
            return
        self.connection_status_changed.emit("connecting")
        logger.info("[Companion] starting — relay: %s", self._settings.companion_relay_url)
        self._connect()

    def stop(self) -> None:
        if self._ws_client:
            self._ws_client.close()
            self._ws_client = None
        self.connection_status_changed.emit("disabled")
        logger.info("[Companion] stopped")

    def update_settings(self, settings: AppSettings) -> None:
        was_enabled = self._settings.companion_enabled
        self._settings = settings
        if was_enabled:
            self.stop()
        if settings.companion_enabled:
            self.start()

    # ── Bridge / Runner / Store wiring ──────────────────────

    def set_bridge(self, bridge: Any) -> None:
        old = self._bridge
        if old is not None:
            try:
                old.contentDelta.disconnect(self._on_bridge_content_delta)
                old.reasoningDelta.disconnect(self._on_bridge_reasoning_delta)
                old.streamDone.disconnect(self._on_bridge_stream_done)
                old.apiError.disconnect(self._on_bridge_api_error)
                old.finished.disconnect(self._on_bridge_finished)
            except (TypeError, RuntimeError) as exc:
                logger.debug("[Companion] bridge disconnect skipped: %s", exc)
        self._bridge = bridge
        if bridge is not None:
            bridge.contentDelta.connect(self._on_bridge_content_delta)
            bridge.reasoningDelta.connect(self._on_bridge_reasoning_delta)
            bridge.streamDone.connect(self._on_bridge_stream_done)
            bridge.apiError.connect(self._on_bridge_api_error)
            bridge.finished.connect(self._on_bridge_finished)

    def set_drone_runner(self, runner: Any) -> None:
        self._drone_runner = runner

    def set_project_store(self, store: Any) -> None:
        self._project_store = store

    def set_workspace_root(self, path: str) -> None:
        self._workspace_root = path

    def set_current_project(self, project_id: str, project_name: str = "") -> None:
        self._current_project_id = project_id
        self._paired_project_name = project_name

    def set_current_conversation(self, conversation_id: str) -> None:
        self._current_conversation_id = conversation_id
        self._conversation_loaded = bool(conversation_id)

    # ── Send ────────────────────────────────────────────────

    def send_event(self, event: dict) -> None:
        if self._ws_client and self._ws_client.is_connected:
            self._ws_client.send(json.dumps(event))

    def _reply_to_sender(
        self,
        msg: dict,
        msg_type: str,
        payload: dict,
        *,
        project_id: str = "",
        conversation_id: str = "",
    ) -> None:
        sender_phone_id = msg.get("sender_device_id", "")
        if not sender_phone_id:
            logger.warning("[Companion] cannot reply to %s: missing sender_device_id", msg_type)
            return
        self.send_event(make_envelope(
            msg_type,
            payload,
            desktop_id=sender_phone_id,
            project_id=project_id,
            conversation_id=conversation_id,
            in_response_to=msg.get("id", ""),
        ))

    # ── Pairing ─────────────────────────────────────────────

    def generate_new_pairing_code(self) -> str:
        """Generate a new pairing code and emit signal."""
        # Invalidate any existing code
        if self._current_pairing_code:
            invalidate_pairing_code(self._current_pairing_code)

        code = generate_pairing_code()
        self._current_pairing_code = code
        self.pairing_code_available.emit(code)
        logger.info("[Companion] Pairing code generated: %s", code)
        return code

    def start_pairing(self) -> dict:
        """Create a fresh pairing code and return a structured payload.

        Returns a dict with: code, expires_at (unix), ticket, desktop_name,
        pair_url (the URL the phone opens to auto-fill the pair screen).
        The pair URL points at the configured companion web URL with a
        time-limited ticket — see AppSettings.companion_web_url.
        """
        code = self.generate_new_pairing_code()
        expires_at = pairing_code_expiry(code) or 0.0
        desktop_id = get_device_id()
        desktop_name = self._settings.companion_display_name or get_device_display_name()
        web_url = (self._settings.companion_web_url or "http://localhost:5173").rstrip("/")

        ticket = generate_ticket(
            desktop_id=desktop_id,
            pairing_code=code,
            desktop_name=desktop_name,
            project_id=self._current_project_id,
            conversation_id=self._current_conversation_id,
        )

        # Register the ticket with the relay so it can resolve context
        self.send_event(make_envelope("ticket.register", {
            "ticket": ticket,
            "desktop_id": desktop_id,
            "desktop_name": desktop_name,
            "code": code,
            "project_id": self._current_project_id,
            "conversation_id": self._current_conversation_id,
        }))

        pair_url = f"{web_url}/pair?ticket={ticket}"
        return {
            "code": code,
            "expires_at": expires_at,
            "ticket": ticket,
            "desktop_name": desktop_name,
            "pair_url": pair_url,
        }

    def cancel_pairing(self) -> None:
        """Invalidate the active pairing code (user closed the pair dialog)."""
        if self._current_pairing_code:
            invalidate_pairing_code(self._current_pairing_code)
            self._current_pairing_code = ""
            self.pairing_code_invalidated.emit()

    def _handle_pair_verify(self, msg: dict) -> None:
        """Desktop receives a pair.verify from relay — validate code and respond."""
        payload = msg.get("payload", {})
        code = payload.get("code", "")
        phone_id = payload.get("phone_id", "")
        phone_name = payload.get("device_name", "Phone")
        original_msg_id = payload.get("original_msg_id", msg.get("id", ""))

        if not code:
            self.send_event(make_envelope("pair.error", {
                "message": "No pairing code provided",
            }, in_response_to=original_msg_id))
            return

        if not validate_pairing_code(code):
            self.send_event(make_envelope("pair.error", {
                "message": "Invalid or expired pairing code",
            }, in_response_to=original_msg_id))
            return

        # Clear used code
        self._current_pairing_code = ""

        token = create_device_token(
            desktop_id=get_device_id(),
            device_name=phone_name,
            role="phone"
        )

        # Resolve safe context to deliver to the phone after pairing
        project_name = ""
        if self._current_project_id and self._project_store:
            try:
                project = self._project_store.load_project(self._current_project_id)
                if project:
                    project_name = project.name
            except Exception as exc:
                logger.debug("[Companion] could not resolve project name for pairing context: %s", exc)

        safe_context = {
            "project_id": self._current_project_id,
            "project_name": project_name,
            "conversation_id": self._current_conversation_id,
        }
        self._paired_context = safe_context

        # Send confirmation back through relay with safe context
        self.send_event(make_envelope("pair.confirmed", {
            "token": token,
            "desktop_id": get_device_id(),
            "desktop_name": get_device_display_name(),
            "phone_id": phone_id,
            "device_name": phone_name,
            **safe_context,
        }, in_response_to=original_msg_id))

        # Emit signal for UI
        self.pairing_complete.emit(phone_name)
        logger.info("[Companion] Phone paired: %s | token issued", phone_name)

    def _handle_pair_cancel(self, msg: dict) -> None:
        """Cancel active pairing."""
        if self._current_pairing_code:
            invalidate_pairing_code(self._current_pairing_code)
            self._current_pairing_code = ""
        self.pairing_code_invalidated.emit()
        logger.info("[Companion] Pairing cancelled")

    def _handle_companion_verify(self, msg: dict) -> None:
        """Respond to phone connection verification — lightweight, read-only."""
        desktop_name = self._settings.companion_display_name or get_device_display_name()
        self._reply_to_sender(msg, "companion.verify_ack", {
            "desktop_id": get_device_id(),
            "desktop_name": desktop_name,
            "project_id": self._current_project_id,
            "conversation_id": self._current_conversation_id,
        })

    # ── Internal ────────────────────────────────────────────

    def _connect(self) -> None:
        url = self._settings.companion_relay_url
        if not url:
            logger.warning("[Companion] no relay URL configured")
            self.connection_status_changed.emit("error")
            return
        # Ensure ws:// prefix
        if not url.startswith("ws"):
            url = f"ws://{url}"
        # Ensure /ws path
        if not url.endswith("/ws"):
            url = url.rstrip("/") + "/ws"

        device_id = get_device_id()
        desktop_secret = os.environ.get("AURA_COMPANION_DESKTOP_SECRET", "")

        client = CompanionWsClient(url, device_id, desktop_secret, self)
        self._ws_client = client
        client.connected.connect(lambda c=client: self._on_connected(c))
        client.disconnected.connect(lambda c=client: self._on_disconnected(c))
        client.error.connect(lambda err, c=client: self._on_client_error(c, err))
        client.message_received.connect(self._on_raw_message)
        client.connect_to_relay()

    def _on_client_error(self, client: CompanionWsClient, error_str: str) -> None:
        if client is not self._ws_client:
            return
        self.connection_error.emit(error_str)

    def _on_connected(self, client: CompanionWsClient | None = None) -> None:
        if client is not None and client is not self._ws_client:
            return
        self.connection_status_changed.emit("connected")
        logger.info("[Companion] connected to Relay")
        # Send desktop.online event
        self.send_event(make_envelope("desktop.online", {
            "display_name": get_device_display_name(),
            "aura_version": __version__,
            "capabilities": ["chat.send", "project.list_recent", "conversation.*"],
        }, desktop_id=get_device_id()))

    def _on_disconnected(self, client: CompanionWsClient | None = None) -> None:
        if client is not None and client is not self._ws_client:
            return
        self.connection_status_changed.emit("error")
        logger.warning("[Companion] disconnected from Relay")

    def _on_raw_message(self, raw: str) -> None:
        """Handle an incoming message from Relay."""
        try:
            msg = json.loads(raw)
        except json.JSONDecodeError:
            logger.warning("[Companion] invalid JSON: %s", raw[:200])
            return
        self.message_received.emit(msg)
        # Phase 2: route chat commands through bridge
        msg_type = msg.get("type", "")
        if msg_type == "chat.send":
            self._handle_chat_send(msg)
        elif msg_type == "chat.cancel":
            self._handle_chat_cancel(msg)
        elif msg_type == "project.list_recent":
            self._handle_project_list_recent(msg)
        elif msg_type == "conversation.list":
            self._handle_conversation_list(msg)
        elif msg_type == "conversation.select":
            self._handle_conversation_select(msg)
        elif msg_type == "drone.list_recent":
            self._handle_drone_list_recent(msg)
        elif msg_type == "drone.status":
            self._handle_drone_status(msg)
        elif msg_type == "receipt.list_recent":
            self._handle_receipt_list_recent(msg)
        elif msg_type == "pair.verify":
            self._handle_pair_verify(msg)
        elif msg_type == "pair.cancel":
            self._handle_pair_cancel(msg)
        elif msg_type == "companion.verify":
            self._handle_companion_verify(msg)
        elif msg_type == "conversation.history":
            self._handle_conversation_history(msg)

    # ── Bridge chat handlers ────────────────────────────────

    def _handle_chat_send(self, msg: dict) -> None:
        payload = msg.get("payload", {})
        sender_phone_id = msg.get("sender_device_id", "")
        chat_id = msg.get("id", "")
        text = (payload.get("text", "") or "").strip()

        def reply_error(message: str) -> None:
            self._reply_to_sender(msg, "chat.error", {"message": message})

        if self._bridge is None:
            reply_error("Companion is not connected to a conversation session.")
            return

        if not text:
            reply_error("Message text is empty.")
            return

        project_id = (
            msg.get("project_id")
            or payload.get("project_id", "")
            or self._current_project_id
        )
        conversation_id = (
            msg.get("conversation_id")
            or payload.get("conversation_id", "")
            or self._current_conversation_id
        )
        if not project_id or not conversation_id:
            reply_error("Open or create a conversation in Aura Desktop, then try again.")
            return

        self._current_project_id = project_id
        self._current_conversation_id = conversation_id
        self._conversation_loaded = True

        if self._bridge.is_running():
            reply_error("A conversation is already in progress on the desktop.")
            return

        self._pending_chat_id = chat_id
        self._pending_chat_phone_id = sender_phone_id
        self._bridge.history.append_user_text(text)
        model = resolve_role_default_model(self._settings.planner_provider, "planner")
        thinking = self._settings.default_planner_thinking
        self._bridge.send(model=model, thinking=thinking, max_tool_rounds=self._settings.max_tool_rounds)

    def _handle_chat_cancel(self, msg: dict) -> None:
        if self._bridge and self._bridge.is_running():
            self._bridge.request_cancel()

    # ── Phase 3 command handlers ───────────────────────────────

    def _handle_project_list_recent(self, msg: dict) -> None:
        """List recent projects."""
        if not self._workspace_root:
            self._reply_to_sender(msg, "project.list_result", {
                "projects": [],
            })
            return
        try:
            store = ProjectStore()
            projects = store.list_projects()
            projects.sort(key=lambda p: p.updated_at, reverse=True)
            dtos = []
            for p in projects[:20]:
                thread_count = 0
                try:
                    threads = store.list_threads(p)
                    thread_count = len(threads)
                except Exception as exc:
                    logger.debug("[Companion] thread count for %s: %s", p.id, exc)
                dtos.append(CompanionProject(
                    id=p.id,
                    name=p.name,
                    updated_at=p.updated_at,
                    thread_count=thread_count,
                ).to_dict())
            self._reply_to_sender(msg, "project.list_result", {
                "projects": dtos,
            })
        except Exception as exc:
            logger.error("[Companion] project.list_recent error: %s", exc)
            self._reply_to_sender(msg, "project.list_result", {
                "projects": [],
                "error": str(exc),
            })

    def _handle_conversation_list(self, msg: dict) -> None:
        """List threads for the current project, or a specified project."""
        payload = msg.get("payload", {})
        project_id = payload.get("project_id", self._current_project_id)
        if not project_id or not self._workspace_root:
            self._reply_to_sender(msg, "conversation.list_result", {
                "threads": [],
            })
            return
        try:
            store = ProjectStore()
            project = store.load_project(project_id)
            if not project:
                self._reply_to_sender(msg, "conversation.list_result", {
                    "threads": [],
                    "error": "Project not found",
                })
                return
            threads = store.list_threads(project)
            threads.sort(key=lambda t: t.updated_at, reverse=True)
            dtos = []
            for t in threads[:50]:
                dtos.append(CompanionThread(
                    id=t.id,
                    title=t.title or "Untitled",
                    updated_at=t.updated_at,
                    is_current=(t.id == self._current_conversation_id),
                ).to_dict())
            self._reply_to_sender(msg, "conversation.list_result", {
                "threads": dtos,
            })
        except Exception as exc:
            logger.error("[Companion] conversation.list error: %s", exc)
            self._reply_to_sender(msg, "conversation.list_result", {
                "threads": [],
                "error": str(exc),
            })

    def _handle_conversation_select(self, msg: dict) -> None:
        """Select a thread as the active conversation."""
        payload = msg.get("payload", {})
        thread_id = payload.get("thread_id", "")
        project_id = payload.get("project_id", self._current_project_id)
        if not thread_id or not project_id:
            self._reply_to_sender(msg, "conversation.selected", {
                "error": "Missing thread_id or project_id",
            })
            return
        try:
            store = ProjectStore()
            project = store.load_project(project_id)
            if not project:
                self._reply_to_sender(msg, "conversation.selected", {
                    "error": "Project not found",
                })
                return
            thread = store.load_thread(project, thread_id)
            if not thread:
                self._reply_to_sender(msg, "conversation.selected", {
                    "error": "Thread not found",
                })
                return
            if thread.conversation_path is None:
                self._reply_to_sender(msg, "conversation.selected", {
                    "error": "Thread has no conversation file",
                })
                return
            if self._bridge and self._bridge.is_running():
                self._reply_to_sender(msg, "conversation.selected", {
                    "error": "Desktop is busy",
                })
                return
            self._pending_select_msg = msg
            self.conversation_selected_by_companion.emit(project.root_path, thread.conversation_path)
        except Exception as exc:
            logger.error("[Companion] conversation.select error: %s", exc)
            self._reply_to_sender(msg, "conversation.selected", {
                "error": str(exc),
            })

    def complete_conversation_select(self, success: bool, error_text: str = "") -> None:
        """Called by MainWindow after attempting to load a companion-requested thread."""
        msg = self._pending_select_msg
        self._pending_select_msg = None
        if msg is None:
            return
        payload = msg.get("payload", {})
        thread_id = payload.get("thread_id", "")
        project_id = payload.get("project_id", self._current_project_id)
        if success:
            self._current_project_id = project_id
            self._current_conversation_id = thread_id
            self._conversation_loaded = True
            self._reply_to_sender(msg, "conversation.selected", {
                "project_id": project_id,
                "thread_id": thread_id,
            })
        else:
            self._reply_to_sender(msg, "conversation.selected", {
                "project_id": project_id,
                "thread_id": thread_id,
                "status": "error",
                "error": error_text,
            })

    def _handle_conversation_history(self, msg: dict) -> None:
        payload = msg.get("payload", {})
        project_id = payload.get("project_id") or self._current_project_id
        thread_id = payload.get("thread_id") or self._current_conversation_id
        if not project_id or not thread_id:
            self._reply_to_sender(msg, "conversation.history_result", {
                "project_id": project_id,
                "thread_id": thread_id,
                "messages": [],
                "error": "Missing project_id or thread_id",
            })
            return
        try:
            if self._conversation_loaded and thread_id == self._current_conversation_id and self._bridge is not None:
                raw_messages = self._bridge.history.messages
            else:
                store = ProjectStore()
                project = store.load_project(project_id)
                if not project:
                    self._reply_to_sender(msg, "conversation.history_result", {
                        "project_id": project_id,
                        "thread_id": thread_id,
                        "messages": [],
                        "error": "Project not found",
                    })
                    return
                thread = store.load_thread(project, thread_id)
                if not thread or thread.conversation_path is None:
                    self._reply_to_sender(msg, "conversation.history_result", {
                        "project_id": project_id,
                        "thread_id": thread_id,
                        "messages": [],
                        "error": "Thread or conversation file not found",
                    })
                    return
                loaded = load_conversation(thread.conversation_path)
                raw_messages = loaded.history.messages

            mobile_messages: list[dict] = []
            for m in raw_messages:
                role = m.get("role", "")
                if role in ("tool", "system"):
                    continue
                content = m.get("content")
                if role == "user":
                    if isinstance(content, str):
                        text = content
                    elif isinstance(content, list):
                        parts = [
                            p.get("text", "")
                            for p in content
                            if isinstance(p, dict) and p.get("type") == "text"
                        ]
                        text = " ".join(parts)
                    else:
                        continue
                    if not text:
                        continue
                    mobile_messages.append({"role": role, "content": text})
                elif role == "assistant":
                    if not isinstance(content, str) or not content:
                        continue
                    mobile_messages.append({"role": role, "content": content})

            self._reply_to_sender(msg, "conversation.history_result", {
                "project_id": project_id,
                "thread_id": thread_id,
                "messages": mobile_messages,
            })
        except Exception as exc:
            logger.error("[Companion] conversation.history error: %s", exc)
            self._reply_to_sender(msg, "conversation.history_result", {
                "project_id": project_id,
                "thread_id": thread_id,
                "messages": [],
                "error": str(exc),
            })

    def _handle_drone_list_recent(self, msg: dict) -> None:
        """List recent drone runs."""
        if not self._workspace_root:
            self._reply_to_sender(msg, "drone.list_result", {
                "runs": [],
            })
            return
        try:
            root = Path(self._workspace_root)
            runs = RunHistoryStore.list_runs(root, limit=20)
            summaries = []
            for r in runs:
                summaries.append(ActiveRunSummary(
                    run_id=r.get("run_id", ""),
                    kind="drone",
                    label=r.get("drone_name", r.get("drone_id", "Drone")),
                    status=r.get("status", "unknown"),
                    started_at=r.get("started_at"),
                ).to_dict())
            self._reply_to_sender(msg, "drone.list_result", {
                "runs": summaries,
            })
        except Exception as exc:
            logger.error("[Companion] drone.list_recent error: %s", exc)
            self._reply_to_sender(msg, "drone.list_result", {
                "runs": [],
                "error": str(exc),
            })

    def _handle_drone_status(self, msg: dict) -> None:
        """Report current drone runner status."""
        if self._drone_runner is not None:
            try:
                state = self._drone_runner.run_state()
                from datetime import datetime
                started_at_str = datetime.fromtimestamp(state.started_at).isoformat() if state.started_at else None
                summary = ActiveRunSummary(
                    run_id=state.run_id,
                    kind="drone",
                    label=state.drone.name if hasattr(state, 'drone') and state.drone else "Drone",
                    status=state.status,
                    started_at=started_at_str,
                ).to_dict()
                self._reply_to_sender(msg, "drone.status_result", {
                    "running": True,
                    "run": summary,
                })
                return
            except Exception as exc:
                logger.error("[Companion] drone.status error: %s", exc)
        self._reply_to_sender(msg, "drone.status_result", {
            "running": False,
            "run": None,
        })

    def _handle_receipt_list_recent(self, msg: dict) -> None:
        """List recent receipts (same data as drone runs, ReceiptSummary DTO)."""
        if not self._workspace_root:
            self._reply_to_sender(msg, "receipt.list_result", {
                "receipts": [],
            })
            return
        try:
            root = Path(self._workspace_root)
            runs = RunHistoryStore.list_runs(root, limit=20)
            receipts = []
            for r in runs:
                receipts.append(ReceiptSummary(
                    run_id=r.get("run_id", ""),
                    kind="drone",
                    label=r.get("drone_name", r.get("drone_id", "Drone")),
                    status=r.get("status", "unknown"),
                    completed_at=r.get("ended_at", r.get("started_at", "")),
                    summary=r.get("summary", ""),
                ).to_dict())
            self._reply_to_sender(msg, "receipt.list_result", {
                "receipts": receipts,
            })
        except Exception as exc:
            logger.error("[Companion] receipt.list_recent error: %s", exc)
            self._reply_to_sender(msg, "receipt.list_result", {
                "receipts": [],
                "error": str(exc),
            })

    def _on_bridge_content_delta(self, text: str) -> None:
        self.send_event(make_envelope("chat.message.delta", {
            "role": "assistant",
            "type": "content",
            "text": text,
        }, desktop_id=self._pending_chat_phone_id, in_response_to=self._pending_chat_id))

    def _on_bridge_reasoning_delta(self, text: str) -> None:
        self.send_event(make_envelope("chat.message.delta", {
            "role": "assistant",
            "type": "reasoning",
            "text": text,
        }, desktop_id=self._pending_chat_phone_id, in_response_to=self._pending_chat_id))

    def _on_bridge_stream_done(self, finish_reason: str, full_message: dict) -> None:
        content = full_message.get("content", "") if isinstance(full_message, dict) else ""
        if isinstance(content, list):
            texts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
            content = " ".join(texts)
        self.send_event(make_envelope("chat.message.complete", {
            "role": "assistant",
            "text": content or "…",
            "finish_reason": finish_reason,
        }, desktop_id=self._pending_chat_phone_id, in_response_to=self._pending_chat_id))
        self._pending_chat_id = ""
        self._pending_chat_phone_id = ""

    def _on_bridge_api_error(self, status_code: int, message: str) -> None:
        self.send_event(make_envelope("chat.error", {
            "message": f"API error ({status_code}): {message}",
        }, desktop_id=self._pending_chat_phone_id, in_response_to=self._pending_chat_id))
        self._pending_chat_id = ""
        self._pending_chat_phone_id = ""

    def _on_bridge_finished(self) -> None:
        if self._pending_chat_id:
            self.send_event(make_envelope("chat.message.complete", {
                "role": "assistant",
                "text": "",
                "finish_reason": "cancelled",
            }, desktop_id=self._pending_chat_phone_id, in_response_to=self._pending_chat_id))
            self._pending_chat_id = ""
            self._pending_chat_phone_id = ""
