"""Session management for active calls."""

import asyncio
import hashlib
import json
import re
import time
from enum import Enum
from typing import Optional, Dict, Any, List
from loguru import logger

from app.backend_client import (
    fetch_assistant_config,
    create_history_call_record,
    add_history_transcript,
    finalize_history_call_record,
)
from core.transports import BaseTransport
from core.duplex_pipeline import DuplexPipeline
from core.conversation import ConversationTurn
from core.workflow_runner import WorkflowRunner, WorkflowTransition, WorkflowNodeDef, WorkflowEdgeDef
from app.config import settings
from services.base import LLMMessage
from models.ws_v1 import (
    parse_client_message,
    ev,
    HelloMessage,
    SessionStartMessage,
    SessionStopMessage,
    InputTextMessage,
    ResponseCancelMessage,
    ToolCallResultsMessage,
)


class WsSessionState(str, Enum):
    """Protocol state machine for WS sessions."""

    WAIT_HELLO = "wait_hello"
    WAIT_START = "wait_start"
    ACTIVE = "active"
    STOPPED = "stopped"


class Session:
    """
    Manages a single call session.

    Handles command routing, audio processing, and session lifecycle.
    Uses full duplex voice conversation pipeline.
    """

    TRACK_AUDIO_IN = "audio_in"
    TRACK_AUDIO_OUT = "audio_out"
    TRACK_CONTROL = "control"
    AUDIO_FRAME_BYTES = 640  # 16k mono pcm_s16le, 20ms
    _CLIENT_METADATA_OVERRIDES = {
        "firstTurnMode",
        "greeting",
        "generatedOpenerEnabled",
        "systemPrompt",
        "output",
        "bargeIn",
        "knowledge",
        "knowledgeBaseId",
        "history",
        "userId",
        "assistantId",
        "source",
    }
    _CLIENT_METADATA_ID_KEYS = {
        "appId",
        "app_id",
        "channel",
        "configVersionId",
        "config_version_id",
    }

    def __init__(self, session_id: str, transport: BaseTransport, use_duplex: bool = None):
        """
        Initialize session.

        Args:
            session_id: Unique session identifier
            transport: Transport instance for communication
            use_duplex: Whether to use duplex pipeline (defaults to settings.duplex_enabled)
        """
        self.id = session_id
        self.transport = transport
        self.use_duplex = use_duplex if use_duplex is not None else settings.duplex_enabled

        self.pipeline = DuplexPipeline(
            transport=transport,
            session_id=session_id,
            system_prompt=settings.duplex_system_prompt,
            greeting=settings.duplex_greeting
        )

        # Session state
        self.created_at = None
        self.state = "created"  # Legacy call state for /call/lists
        self.ws_state = WsSessionState.WAIT_HELLO
        self._pipeline_started = False
        self.protocol_version: Optional[str] = None
        self.authenticated: bool = False

        # Track IDs
        self.current_track_id: str = self.TRACK_CONTROL
        self._event_seq: int = 0
        self._history_call_id: Optional[str] = None
        self._history_turn_index: int = 0
        self._history_call_started_mono: Optional[float] = None
        self._history_finalized: bool = False
        self._cleanup_lock = asyncio.Lock()
        self._cleaned_up = False
        self.workflow_runner: Optional[WorkflowRunner] = None
        self._workflow_last_user_text: str = ""
        self._workflow_initial_node: Optional[WorkflowNodeDef] = None

        self.pipeline.set_event_sequence_provider(self._next_event_seq)
        self.pipeline.conversation.on_turn_complete(self._on_turn_complete)

        logger.info(f"Session {self.id} created (duplex={self.use_duplex})")

    async def handle_text(self, text_data: str) -> None:
        """
        Handle incoming text data (WS v1 JSON control messages).

        Args:
            text_data: JSON text data
        """
        try:
            data = json.loads(text_data)
            message = parse_client_message(data)
            await self._handle_v1_message(message)

        except json.JSONDecodeError as e:
            logger.error(f"Session {self.id} JSON decode error: {e}")
            await self._send_error("client", f"Invalid JSON: {e}", "protocol.invalid_json")

        except ValueError as e:
            logger.error(f"Session {self.id} command parse error: {e}")
            await self._send_error("client", f"Invalid message: {e}", "protocol.invalid_message")

        except Exception as e:
            logger.error(f"Session {self.id} handle_text error: {e}", exc_info=True)
            await self._send_error("server", f"Internal error: {e}", "server.internal")

    async def handle_audio(self, audio_bytes: bytes) -> None:
        """
        Handle incoming audio data.

        Args:
            audio_bytes: PCM audio data
        """
        if self.ws_state != WsSessionState.ACTIVE:
            await self._send_error(
                "client",
                "Audio received before session.start",
                "protocol.order",
                stage="protocol",
                retryable=False,
            )
            return

        try:
            if not audio_bytes:
                return
            if len(audio_bytes) % 2 != 0:
                await self._send_error(
                    "client",
                    "Invalid PCM payload: odd number of bytes",
                    "audio.invalid_pcm",
                    stage="audio",
                    retryable=False,
                )
                return

            frame_bytes = self.AUDIO_FRAME_BYTES
            if len(audio_bytes) % frame_bytes != 0:
                await self._send_error(
                    "client",
                    f"Audio frame size must be a multiple of {frame_bytes} bytes (20ms PCM)",
                    "audio.frame_size_mismatch",
                    stage="audio",
                    retryable=False,
                )
                return

            for i in range(0, len(audio_bytes), frame_bytes):
                frame = audio_bytes[i : i + frame_bytes]
                await self.pipeline.process_audio(frame)
        except Exception as e:
            logger.error(f"Session {self.id} handle_audio error: {e}", exc_info=True)
            await self._send_error(
                "server",
                f"Audio processing failed: {e}",
                "audio.processing_failed",
                stage="audio",
                retryable=True,
            )

    async def _handle_v1_message(self, message: Any) -> None:
        """Route validated WS v1 message to handlers."""
        msg_type = message.type
        logger.info(f"Session {self.id} received message: {msg_type}")

        if isinstance(message, HelloMessage):
            await self._handle_hello(message)
            return

        # All messages below require hello handshake first
        if self.ws_state == WsSessionState.WAIT_HELLO:
            await self._send_error(
                "client",
                "Expected hello message first",
                "protocol.order",
            )
            return

        if isinstance(message, SessionStartMessage):
            await self._handle_session_start(message)
            return

        # All messages below require active session
        if self.ws_state != WsSessionState.ACTIVE:
            await self._send_error(
                "client",
                f"Message '{msg_type}' requires active session",
                "protocol.order",
            )
            return

        if isinstance(message, InputTextMessage):
            await self.pipeline.process_text(message.text)
        elif isinstance(message, ResponseCancelMessage):
            if message.graceful:
                logger.info(f"Session {self.id} graceful response.cancel")
            else:
                await self.pipeline.interrupt()
        elif isinstance(message, ToolCallResultsMessage):
            await self.pipeline.handle_tool_call_results([item.model_dump() for item in message.results])
        elif isinstance(message, SessionStopMessage):
            await self._handle_session_stop(message.reason)
        else:
            await self._send_error("client", f"Unsupported message type: {msg_type}", "protocol.unsupported")

    async def _handle_hello(self, message: HelloMessage) -> None:
        """Handle initial hello/auth/version negotiation."""
        if self.ws_state != WsSessionState.WAIT_HELLO:
            await self._send_error("client", "Duplicate hello", "protocol.order")
            return

        if message.version != settings.ws_protocol_version:
            await self._send_error(
                "client",
                f"Unsupported protocol version '{message.version}'",
                "protocol.version_unsupported",
            )
            await self.transport.close()
            self.ws_state = WsSessionState.STOPPED
            return

        auth_payload = message.auth
        api_key = auth_payload.apiKey if auth_payload else None
        jwt = auth_payload.jwt if auth_payload else None

        if settings.ws_api_key:
            if api_key != settings.ws_api_key:
                await self._send_error("auth", "Invalid API key", "auth.invalid_api_key")
                await self.transport.close()
                self.ws_state = WsSessionState.STOPPED
                return
        elif settings.ws_require_auth and not (api_key or jwt):
            await self._send_error("auth", "Authentication required", "auth.required")
            await self.transport.close()
            self.ws_state = WsSessionState.STOPPED
            return

        self.authenticated = True
        self.protocol_version = message.version
        self.ws_state = WsSessionState.WAIT_START
        await self._send_event(
            ev(
                "hello.ack",
                version=self.protocol_version,
            )
        )

    async def _handle_session_start(self, message: SessionStartMessage) -> None:
        """Handle explicit session start after successful hello."""
        if self.ws_state != WsSessionState.WAIT_START:
            await self._send_error("client", "Duplicate session.start", "protocol.order")
            return

        raw_metadata = message.metadata or {}
        workflow_runtime = self._bootstrap_workflow(raw_metadata)
        server_runtime = await self._load_server_runtime_metadata(raw_metadata, workflow_runtime)
        client_runtime = self._sanitize_client_metadata(raw_metadata)
        metadata = self._merge_runtime_metadata(server_runtime, self._sanitize_untrusted_runtime_metadata(workflow_runtime))
        metadata = self._merge_runtime_metadata(metadata, client_runtime)

        # Create history call record early so later turn callbacks can append transcripts.
        await self._start_history_bridge(metadata)

        # Apply runtime service/prompt overrides from backend if provided
        self.pipeline.apply_runtime_overrides(metadata)

        # Start duplex pipeline
        if not self._pipeline_started:
            await self.pipeline.start()
            self._pipeline_started = True
            logger.info(f"Session {self.id} duplex pipeline started")

        self.state = "accepted"
        self.ws_state = WsSessionState.ACTIVE
        await self._send_event(
            ev(
                "session.started",
                trackId=self.current_track_id,
                tracks={
                    "audio_in": self.TRACK_AUDIO_IN,
                    "audio_out": self.TRACK_AUDIO_OUT,
                    "control": self.TRACK_CONTROL,
                },
                audio=message.audio.model_dump() if message.audio else {},
            )
        )
        await self._send_event(
            ev(
                "config.resolved",
                trackId=self.TRACK_CONTROL,
                config=self._build_config_resolved(metadata),
            )
        )
        if self.workflow_runner and self._workflow_initial_node:
            await self._send_event(
                ev(
                    "workflow.started",
                    workflowId=self.workflow_runner.workflow_id,
                    workflowName=self.workflow_runner.name,
                    nodeId=self._workflow_initial_node.id,
                )
            )
            await self._send_event(
                ev(
                    "workflow.node.entered",
                    workflowId=self.workflow_runner.workflow_id,
                    nodeId=self._workflow_initial_node.id,
                    nodeName=self._workflow_initial_node.name,
                    nodeType=self._workflow_initial_node.node_type,
                )
            )

    async def _handle_session_stop(self, reason: Optional[str]) -> None:
        """Handle session stop."""
        if self.ws_state == WsSessionState.STOPPED:
            return

        stop_reason = reason or "client_requested"
        self.state = "hungup"
        self.ws_state = WsSessionState.STOPPED
        await self._send_event(
            ev(
                "session.stopped",
                reason=stop_reason,
            )
        )
        await self._finalize_history(status="connected")
        await self.transport.close()

    async def _send_error(
        self,
        sender: str,
        error_message: str,
        code: str,
        stage: Optional[str] = None,
        retryable: Optional[bool] = None,
        track_id: Optional[str] = None,
    ) -> None:
        """
        Send error event to client.

        Args:
            sender: Component that generated the error
            error_message: Error message
            code: Machine-readable error code
        """
        resolved_stage = stage or self._infer_error_stage(code)
        resolved_retryable = retryable if retryable is not None else (resolved_stage in {"asr", "llm", "tts", "tool", "audio"})
        resolved_track_id = track_id or self._error_track_id(resolved_stage, code)
        await self._send_event(
            ev(
                "error",
                sender=sender,
                code=code,
                message=error_message,
                stage=resolved_stage,
                retryable=resolved_retryable,
                trackId=resolved_track_id,
                data={
                    "error": {
                        "stage": resolved_stage,
                        "code": code,
                        "message": error_message,
                        "retryable": resolved_retryable,
                    }
                },
            )
        )

    def _get_timestamp_ms(self) -> int:
        """Get current timestamp in milliseconds."""
        import time
        return int(time.time() * 1000)

    async def cleanup(self) -> None:
        """Cleanup session resources."""
        async with self._cleanup_lock:
            if self._cleaned_up:
                return

            self._cleaned_up = True
            logger.info(f"Session {self.id} cleaning up")
            await self._finalize_history(status="connected")
            await self.pipeline.cleanup()
            await self.transport.close()

    async def _start_history_bridge(self, metadata: Dict[str, Any]) -> None:
        """Initialize backend history call record for this session."""
        if self._history_call_id:
            return

        history_meta: Dict[str, Any] = {}
        if isinstance(metadata.get("history"), dict):
            history_meta = metadata["history"]

        raw_user_id = history_meta.get("userId", metadata.get("userId", settings.history_default_user_id))
        try:
            user_id = int(raw_user_id)
        except (TypeError, ValueError):
            user_id = settings.history_default_user_id

        assistant_id = history_meta.get("assistantId", metadata.get("assistantId"))
        source = str(history_meta.get("source", metadata.get("source", "debug")))

        call_id = await create_history_call_record(
            user_id=user_id,
            assistant_id=str(assistant_id) if assistant_id else None,
            source=source,
        )
        if not call_id:
            return

        self._history_call_id = call_id
        self._history_call_started_mono = time.monotonic()
        self._history_turn_index = 0
        self._history_finalized = False
        logger.info(f"Session {self.id} history bridge enabled (call_id={call_id}, source={source})")

    async def _on_turn_complete(self, turn: ConversationTurn) -> None:
        """Process workflow transitions and persist completed turns to history."""
        if turn.text and turn.text.strip():
            role = (turn.role or "").lower()
            if role == "user":
                self._workflow_last_user_text = turn.text.strip()
            elif role == "assistant":
                await self._maybe_advance_workflow(turn.text.strip())

        if not self._history_call_id:
            return
        if not turn.text or not turn.text.strip():
            return

        role = (turn.role or "").lower()
        speaker = "human" if role == "user" else "ai"

        end_ms = 0
        if self._history_call_started_mono is not None:
            end_ms = max(0, int((time.monotonic() - self._history_call_started_mono) * 1000))
        estimated_duration_ms = max(300, min(12000, len(turn.text.strip()) * 80))
        start_ms = max(0, end_ms - estimated_duration_ms)

        turn_index = self._history_turn_index
        await add_history_transcript(
            call_id=self._history_call_id,
            turn_index=turn_index,
            speaker=speaker,
            content=turn.text.strip(),
            start_ms=start_ms,
            end_ms=end_ms,
            duration_ms=max(1, end_ms - start_ms),
        )
        self._history_turn_index += 1

    async def _finalize_history(self, status: str) -> None:
        """Finalize history call record once."""
        if not self._history_call_id or self._history_finalized:
            return

        duration_seconds = 0
        if self._history_call_started_mono is not None:
            duration_seconds = max(0, int(time.monotonic() - self._history_call_started_mono))

        ok = await finalize_history_call_record(
            call_id=self._history_call_id,
            status=status,
            duration_seconds=duration_seconds,
        )
        if ok:
            self._history_finalized = True

    def _bootstrap_workflow(self, metadata: Dict[str, Any]) -> Dict[str, Any]:
        """Parse workflow payload and return initial runtime overrides."""
        payload = metadata.get("workflow")
        self.workflow_runner = WorkflowRunner.from_payload(payload)
        self._workflow_initial_node = None
        if not self.workflow_runner:
            return {}

        node = self.workflow_runner.bootstrap()
        if not node:
            logger.warning(f"Session {self.id} workflow payload had no resolvable start node")
            self.workflow_runner = None
            return {}

        self._workflow_initial_node = node
        logger.info(
            "Session {} workflow enabled: workflow={} start_node={}",
            self.id,
            self.workflow_runner.workflow_id,
            node.id,
        )
        return self.workflow_runner.build_runtime_metadata(node)

    async def _maybe_advance_workflow(self, assistant_text: str) -> None:
        """Attempt node transfer after assistant turn finalization."""
        if not self.workflow_runner or self.ws_state == WsSessionState.STOPPED:
            return

        transition = await self.workflow_runner.route(
            user_text=self._workflow_last_user_text,
            assistant_text=assistant_text,
            llm_router=self._workflow_llm_route,
        )
        if not transition:
            return

        await self._apply_workflow_transition(transition, reason="rule_match")

        # Auto-advance through utility nodes when default edges are present.
        max_auto_hops = 6
        auto_hops = 0
        while self.workflow_runner and self.ws_state != WsSessionState.STOPPED:
            current = self.workflow_runner.current_node
            if not current or current.node_type not in {"start", "tool"}:
                break

            next_default = self.workflow_runner.next_default_transition()
            if not next_default:
                break

            auto_hops += 1
            await self._apply_workflow_transition(next_default, reason="auto")
            if auto_hops >= max_auto_hops:
                logger.warning(
                    "Session {} workflow auto-advance reached hop limit (possible cycle)",
                    self.id,
                )
                break

    async def _apply_workflow_transition(self, transition: WorkflowTransition, reason: str) -> None:
        """Apply graph transition and emit workflow lifecycle events."""
        if not self.workflow_runner:
            return

        self.workflow_runner.apply_transition(transition)
        node = transition.node
        edge = transition.edge

        await self._send_event(
            ev(
                "workflow.edge.taken",
                workflowId=self.workflow_runner.workflow_id,
                edgeId=edge.id,
                fromNodeId=edge.from_node_id,
                toNodeId=edge.to_node_id,
                reason=reason,
            )
        )
        await self._send_event(
            ev(
                "workflow.node.entered",
                workflowId=self.workflow_runner.workflow_id,
                nodeId=node.id,
                nodeName=node.name,
                nodeType=node.node_type,
            )
        )

        node_runtime = self.workflow_runner.build_runtime_metadata(node)
        if node_runtime:
            self.pipeline.apply_runtime_overrides(node_runtime)

        if node.node_type == "tool":
            await self._send_event(
                ev(
                    "workflow.tool.requested",
                    workflowId=self.workflow_runner.workflow_id,
                    nodeId=node.id,
                    tool=node.tool or {},
                )
            )
            return

        if node.node_type == "human_transfer":
            await self._send_event(
                ev(
                    "workflow.human_transfer",
                    workflowId=self.workflow_runner.workflow_id,
                    nodeId=node.id,
                )
            )
            await self._handle_session_stop("workflow_human_transfer")
            return

        if node.node_type == "end":
            await self._send_event(
                ev(
                    "workflow.ended",
                    workflowId=self.workflow_runner.workflow_id,
                    nodeId=node.id,
                )
            )
            await self._handle_session_stop("workflow_end")

    def _next_event_seq(self) -> int:
        self._event_seq += 1
        return self._event_seq

    def _event_source(self, event_type: str) -> str:
        if event_type.startswith("workflow."):
            return "system"
        if event_type.startswith("session.") or event_type.startswith("hello.") or event_type == "heartbeat":
            return "system"
        if event_type == "error":
            return "system"
        return "system"

    def _infer_error_stage(self, code: str) -> str:
        normalized = str(code or "").strip().lower()
        if normalized.startswith("audio."):
            return "audio"
        if normalized.startswith("tool."):
            return "tool"
        if normalized.startswith("asr."):
            return "asr"
        if normalized.startswith("llm."):
            return "llm"
        if normalized.startswith("tts."):
            return "tts"
        return "protocol"

    def _error_track_id(self, stage: str, code: str) -> str:
        if stage in {"audio", "asr"}:
            return self.TRACK_AUDIO_IN
        if stage in {"llm", "tts", "tool"}:
            return self.TRACK_AUDIO_OUT
        if str(code or "").strip().lower().startswith("auth."):
            return self.TRACK_CONTROL
        return self.TRACK_CONTROL

    def _envelope_event(self, event: Dict[str, Any]) -> Dict[str, Any]:
        event_type = str(event.get("type") or "")
        source = str(event.get("source") or self._event_source(event_type))
        track_id = event.get("trackId") or self.TRACK_CONTROL

        data = event.get("data")
        if not isinstance(data, dict):
            data = {}
        for k, v in event.items():
            if k in {"type", "timestamp", "sessionId", "seq", "source", "trackId", "data"}:
                continue
            data.setdefault(k, v)

        event["sessionId"] = self.id
        event["seq"] = self._next_event_seq()
        event["source"] = source
        event["trackId"] = track_id
        event["data"] = data
        return event

    async def _send_event(self, event: Dict[str, Any]) -> None:
        await self.transport.send_event(self._envelope_event(event))

    async def send_heartbeat(self) -> None:
        await self._send_event(ev("heartbeat", trackId=self.TRACK_CONTROL))

    async def _workflow_llm_route(
        self,
        node: WorkflowNodeDef,
        candidates: List[WorkflowEdgeDef],
        context: Dict[str, str],
    ) -> Optional[str]:
        """LLM-based edge routing for condition.type == 'llm' edges."""
        llm_service = self.pipeline.llm_service
        if not llm_service:
            return None

        candidate_rows = [
            {
                "edgeId": edge.id,
                "toNodeId": edge.to_node_id,
                "label": edge.label,
                "hint": edge.condition.get("prompt") if isinstance(edge.condition, dict) else None,
            }
            for edge in candidates
        ]
        system_prompt = (
            "You are a workflow router. Pick exactly one edge. "
            "Return JSON only: {\"edgeId\":\"...\"}."
        )
        user_prompt = json.dumps(
            {
                "nodeId": node.id,
                "nodeName": node.name,
                "userText": context.get("userText", ""),
                "assistantText": context.get("assistantText", ""),
                "candidates": candidate_rows,
            },
            ensure_ascii=False,
        )

        try:
            reply = await llm_service.generate(
                [
                    LLMMessage(role="system", content=system_prompt),
                    LLMMessage(role="user", content=user_prompt),
                ],
                temperature=0.0,
                max_tokens=64,
            )
        except Exception as exc:
            logger.warning(f"Session {self.id} workflow llm routing failed: {exc}")
            return None

        if not reply:
            return None

        edge_ids = {edge.id for edge in candidates}
        node_ids = {edge.to_node_id for edge in candidates}

        parsed = self._extract_json_obj(reply)
        if isinstance(parsed, dict):
            edge_id = parsed.get("edgeId") or parsed.get("id")
            node_id = parsed.get("toNodeId") or parsed.get("nodeId")
            if isinstance(edge_id, str) and edge_id in edge_ids:
                return edge_id
            if isinstance(node_id, str) and node_id in node_ids:
                return node_id

        token_candidates = sorted(edge_ids | node_ids, key=len, reverse=True)
        lowered_reply = reply.lower()
        for token in token_candidates:
            if token.lower() in lowered_reply:
                return token
        return None

    def _merge_runtime_metadata(self, base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
        """Merge node-level metadata overrides into session.start metadata."""
        merged = dict(base or {})
        if not overrides:
            return merged
        for key, value in overrides.items():
            if key == "services" and isinstance(value, dict):
                existing = merged.get("services")
                merged_services = dict(existing) if isinstance(existing, dict) else {}
                merged_services.update(value)
                merged["services"] = merged_services
            else:
                merged[key] = value
        return merged

    async def _load_server_runtime_metadata(
        self,
        client_metadata: Dict[str, Any],
        workflow_runtime: Dict[str, Any],
    ) -> Dict[str, Any]:
        """Load trusted runtime metadata from backend assistant config."""
        assistant_id = (
            workflow_runtime.get("assistantId")
            or client_metadata.get("assistantId")
            or client_metadata.get("appId")
            or client_metadata.get("app_id")
        )
        if assistant_id is None:
            return {}
        if not settings.backend_url:
            return {}

        payload = await fetch_assistant_config(str(assistant_id).strip())
        if not isinstance(payload, dict):
            return {}

        assistant_cfg = payload.get("assistant") if isinstance(payload.get("assistant"), dict) else payload
        if not isinstance(assistant_cfg, dict):
            return {}

        runtime: Dict[str, Any] = {}
        if assistant_cfg.get("systemPrompt") is not None:
            runtime["systemPrompt"] = str(assistant_cfg.get("systemPrompt") or "")
        elif assistant_cfg.get("prompt") is not None:
            runtime["systemPrompt"] = str(assistant_cfg.get("prompt") or "")

        if assistant_cfg.get("greeting") is not None:
            runtime["greeting"] = assistant_cfg.get("greeting")
        elif assistant_cfg.get("opener") is not None:
            runtime["greeting"] = assistant_cfg.get("opener")

        if isinstance(assistant_cfg.get("services"), dict):
            runtime["services"] = assistant_cfg.get("services")
        if isinstance(assistant_cfg.get("tools"), list):
            runtime["tools"] = assistant_cfg.get("tools")

        runtime["assistantId"] = str(assistant_id)
        return runtime

    def _sanitize_untrusted_runtime_metadata(self, metadata: Dict[str, Any]) -> Dict[str, Any]:
        """
        Sanitize untrusted metadata sources.

        This keeps only a small override whitelist and stable config ID fields.
        """
        if not isinstance(metadata, dict):
            return {}

        sanitized: Dict[str, Any] = {}
        for key in self._CLIENT_METADATA_ID_KEYS:
            if key in metadata:
                sanitized[key] = metadata[key]
        for key in self._CLIENT_METADATA_OVERRIDES:
            if key in metadata:
                sanitized[key] = metadata[key]

        return sanitized

    def _sanitize_client_metadata(self, metadata: Dict[str, Any]) -> Dict[str, Any]:
        """Apply client metadata whitelist and remove forbidden secrets."""
        sanitized = self._sanitize_untrusted_runtime_metadata(metadata)
        if isinstance(metadata.get("services"), dict):
            logger.warning(
                "Session {} provided metadata.services from client; client-side service config is ignored",
                self.id,
            )
        return sanitized

    def _build_config_resolved(self, metadata: Dict[str, Any]) -> Dict[str, Any]:
        """Build public resolved config payload (secrets removed)."""
        system_prompt = str(metadata.get("systemPrompt") or self.pipeline.conversation.system_prompt or "")
        prompt_hash = hashlib.sha256(system_prompt.encode("utf-8")).hexdigest() if system_prompt else None
        runtime = self.pipeline.resolved_runtime_config()

        return {
            "appId": metadata.get("appId") or metadata.get("app_id") or metadata.get("assistantId"),
            "channel": metadata.get("channel"),
            "configVersionId": metadata.get("configVersionId") or metadata.get("config_version_id"),
            "prompt": {"sha256": prompt_hash},
            "output": runtime.get("output", {}),
            "services": runtime.get("services", {}),
            "tools": runtime.get("tools", {}),
            "tracks": {
                "audio_in": self.TRACK_AUDIO_IN,
                "audio_out": self.TRACK_AUDIO_OUT,
                "control": self.TRACK_CONTROL,
            },
        }

    def _extract_json_obj(self, text: str) -> Optional[Dict[str, Any]]:
        """Best-effort extraction of a JSON object from freeform text."""
        try:
            parsed = json.loads(text)
            if isinstance(parsed, dict):
                return parsed
        except Exception:
            pass

        match = re.search(r"\{.*\}", text, re.DOTALL)
        if not match:
            return None
        try:
            parsed = json.loads(match.group(0))
            return parsed if isinstance(parsed, dict) else None
        except Exception:
            return None
