"""
LiveKit tracer for Cekura observability platform
Captures transcripts, tool calls, metrics, and recordings from LiveKit Agent sessions
"""

import asyncio
import inspect
import logging
import os
import time
import uuid
from typing import Any, Dict, Optional

from livekit.agents import function_tool, RunContext

# API Configuration
WEBHOOK_ENDPOINT = "/test_framework/v1/livekit-webhook/"
OBSERVABILITY_ENDPOINT = "/observability/v1/livekit/observe/"
TOOLS_ENDPOINT = "/test_framework/v1/aiagents/{agent_id}/tools/"
EGRESS_CREDENTIALS_ENDPOINT = "/observability/v1/livekit/egress-credentials"
API_TIMEOUT = 30
API_MAX_RETRIES = 2

logger = logging.getLogger("cekura")


class EgressCredentials:
    """Container for egress credentials"""
    def __init__(
        self,
        livekit_url: str,
        livekit_api_key: str,
        livekit_api_secret: str,
        access_key: str,
        secret_key: str,
        session_token: str,
        bucket: str,
        region: str,
        key: str
    ):
        self.livekit_url = livekit_url
        self.livekit_api_key = livekit_api_key
        self.livekit_api_secret = livekit_api_secret
        self.access_key = access_key
        self.secret_key = secret_key
        self.session_token = session_token
        self.bucket = bucket
        self.region = region
        self.key = key


class LiveKitTracer:
    """
    LiveKit tracer for automatic metrics collection and analysis using the Cekura platform.

    Provides two entry points for different use cases:
    - track_session(): For simulation/test calls from Cekura with mock tool injection
    - observe_session(): For monitoring live production calls with audio recording

    Both methods automatically collect transcripts, tool calls, metrics, and session metadata
    for analysis in the Cekura platform.

    Usage:
        import os
        from cekura.livekit import LiveKitTracer

        cekura = LiveKitTracer(
            api_key=os.getenv("CEKURA_API_KEY"),
            agent_id=123,
        )

        @server.rtc_session(agent_name="my_agent")
        async def entrypoint(ctx: agents.JobContext):
            assistant = YourAssistant()
            session = agents.AgentSession(...)

            # Track simulation calls with mock tool injection
            await cekura.track_session(ctx, session, assistant)

            # Observe production calls with audio recording
            await cekura.observe_session(ctx, session)

            await session.start(room=ctx.room, agent=assistant)
    """

    def __init__(
        self,
        api_key: str,
        agent_id: int,
        host: str = "https://api.cekura.ai",
        enabled: Optional[bool] = None
    ):
        """
        Initialize LiveKit tracer for metrics collection and analysis.

        Args:
            api_key: API key for authentication with Cekura platform
            agent_id: Unique identifier for your agent from Cekura dashboard
            host: API host URL (default: https://api.cekura.ai)
            enabled: Enable/disable tracer. Defaults to True if not set.
        """
        self.api_key = api_key
        self.agent_id = agent_id
        self.host = host
        self.enabled = enabled if enabled is not None else True

        # Session storage
        self.session_data = {}



    def is_simulation_call(self, ctx) -> bool:
        """
        Check if this is a simulation call by looking for scenario_id in job metadata.

        Args:
            ctx: LiveKit JobContext

        Returns:
            True if simulation call, False otherwise
        """
        if hasattr(ctx, 'job') and ctx.job and hasattr(ctx.job, 'metadata'):
            try:
                import json
                job_metadata_str = ctx.job.metadata
                if job_metadata_str and job_metadata_str.strip():
                    job_metadata = json.loads(job_metadata_str)
                    if 'scenario_id' in job_metadata:
                        logger.info(f"Detected simulation call (scenario_id: {job_metadata.get('scenario_id')})")
                        return True
            except (json.JSONDecodeError, Exception) as e:
                logger.debug(f"Could not parse job metadata: {e}")
        return False

    def get_simulation_data(self, ctx) -> Dict[str, Any]:
        """
        Extract simulation data populated by Cekura when running simulation calls.

        This data is ONLY present when running LiveKit WebRTC calls from the Cekura platform.
        It will be empty for calls made using phone numbers.

        The returned dictionary contains fields like scenario_id, run_id, test_profile_data, and
        additional_config (corresponding to the LiveKit config added in agent settings).

        Args:
            ctx: LiveKit JobContext

        Returns:
            Dictionary containing simulation metadata, or empty dict if not a simulation

        Example:
            {
                "scenario_id": 123,
                "run_id": 456,
                "test_profile_data": {
                    "customer_name": "John Doe",
                    "account_number": "ACC-12345"
                },
                "additional_config": {
                    "sample_key": "sample_value"
                }
            }
        """
        result = {}

        # Extract job metadata
        if hasattr(ctx, 'job') and ctx.job and hasattr(ctx.job, 'metadata'):
            try:
                import json
                job_metadata_str = ctx.job.metadata
                if job_metadata_str and job_metadata_str.strip():
                    job_metadata = json.loads(job_metadata_str)
                    # Add job metadata fields directly to result
                    if isinstance(job_metadata, dict):
                        for key, value in job_metadata.items():
                            # Use safe JSON serialization
                            serialized_value = self._safe_json_value(value)
                            if serialized_value is not None:
                                result[key] = serialized_value
            except (json.JSONDecodeError, Exception) as e:
                logger.warning(f"Could not parse job metadata: {e}")

        # Extract room metadata and place under 'additional_config'
        if hasattr(ctx, 'room') and ctx.room and hasattr(ctx.room, 'metadata'):
            try:
                import json
                room_metadata_str = ctx.room.metadata
                if room_metadata_str and room_metadata_str.strip():
                    room_metadata = json.loads(room_metadata_str)
                    # Use safe JSON serialization for room metadata
                    serialized_room_metadata = self._safe_json_value(room_metadata)
                    if serialized_room_metadata is not None:
                        result['additional_config'] = serialized_room_metadata
            except (json.JSONDecodeError, Exception) as e:
                logger.warning(f"Could not parse room metadata: {e}. Make sure await ctx.connect() is called before get_simulation_data()")

        return result

    def _setup_session_hooks(self, session_id: str, session, ctx) -> None:
        """
        Setup metrics collection and shutdown hooks for a session.

        Args:
            session_id: Session ID
            session: LiveKit AgentSession instance
            ctx: LiveKit JobContext
        """
        # Hook into metrics collection
        @session.on("metrics_collected")
        def on_metrics(ev):
            logger.debug(f"Metrics collected for session {session_id}: {ev.metrics}")
            self._collect_metrics(session_id, ev.metrics)

        # Hook into session close
        @session.on("close")
        def on_close(ev):
            # Stop egress recording if it exists
            if session_id in self.session_data:
                egress_id = self.session_data[session_id].get('egress_id')
                if egress_id:
                    logger.debug(f"Stopping egress: {egress_id} for session {session_id}")
                    asyncio.create_task(self._stop_egress(session_id, egress_id))

        # Register automatic export on shutdown
        async def export_on_shutdown():
            await self._export(session_id, ctx)

        ctx.add_shutdown_callback(export_on_shutdown)

    async def _make_api_request(self, method: str, endpoint: str, json_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
        """
        Make an API request with retry logic for timeouts and 5xx errors.

        Args:
            method: HTTP method ('GET' or 'POST')
            endpoint: API endpoint path (e.g., '/test_framework/v1/aiagents/123/tools/')
            json_data: Optional JSON payload for POST requests

        Returns:
            Dictionary with 'success', 'status_code', 'data' (for GET) or 'response' (for POST), and 'error' fields
        """
        try:
            import aiohttp
        except ImportError:
            logger.error("aiohttp not installed")
            return {"success": False, "error": "aiohttp not installed"}

        # Construct full URL and headers
        url = f"{self.host.rstrip('/')}{endpoint}"
        headers = {"X-CEKURA-API-KEY": self.api_key}
        if method == 'POST':
            headers["Content-Type"] = "application/json"

        for attempt in range(API_MAX_RETRIES + 1):
            try:
                async with aiohttp.ClientSession() as session:
                    request_method = session.get if method == 'GET' else session.post
                    request_kwargs = {
                        'url': url,
                        'headers': headers,
                        'timeout': aiohttp.ClientTimeout(total=API_TIMEOUT)
                    }
                    if json_data and method == 'POST':
                        request_kwargs['json'] = json_data

                    async with request_method(**request_kwargs) as response:
                        status = response.status

                        if status >= 200 and status < 300:
                            # Success
                            if method == 'GET':
                                data = await response.json()
                                return {"success": True, "status_code": status, "data": data}
                            else:
                                response_text = await response.text()
                                return {"success": True, "status_code": status, "response": response_text}
                        elif status >= 500:
                            # 5xx errors - retry
                            response_text = await response.text()
                            if attempt < API_MAX_RETRIES:
                                logger.warning(f"API request failed (status {status}), retrying... (attempt {attempt + 1}/{API_MAX_RETRIES + 1})")
                                continue
                            else:
                                logger.error(f"API request failed after {API_MAX_RETRIES + 1} attempts: {status}")
                                return {"success": False, "status_code": status, "error": response_text}
                        else:
                            # 4xx or other errors - don't retry
                            response_text = await response.text()
                            logger.error(f"API request failed: {status}")
                            return {"success": False, "status_code": status, "error": response_text}
            except (aiohttp.ClientError, TimeoutError) as e:
                # Timeout or connection errors - retry
                if attempt < API_MAX_RETRIES:
                    logger.warning(f"API request error: {e}, retrying... (attempt {attempt + 1}/{API_MAX_RETRIES + 1})")
                    continue
                else:
                    logger.error(f"API request failed after {API_MAX_RETRIES + 1} attempts: {e}")
                    return {"success": False, "error": str(e)}
            except Exception as e:
                logger.error(f"Unexpected API request error: {e}")
                return {"success": False, "error": str(e)}

        return {"success": False, "error": "Max retries exceeded"}

    async def _fetch_mock_tools(self) -> list:
        """Fetch mock tools from Cekura API"""
        endpoint = TOOLS_ENDPOINT.format(agent_id=self.agent_id)
        result = await self._make_api_request('GET', endpoint)

        if result.get('success'):
            tools_data = result.get('data', [])
            logger.info(f"Fetched {len(tools_data)} mock tools from Cekura")
            return tools_data
        else:
            logger.error(f"Failed to fetch mock tools: {result.get('error')}")
            return []

    async def _fetch_egress_credentials(self) -> Optional[EgressCredentials]:
        """
        Fetch and validate egress credentials from Cekura API.

        Returns:
            EgressCredentials object if successful and valid, None otherwise
        """
        endpoint = f"{EGRESS_CREDENTIALS_ENDPOINT}?agent_id={self.agent_id}"
        result = await self._make_api_request('GET', endpoint)

        if not result.get('success'):
            logger.error(f"Failed to fetch egress credentials: {result.get('error')}")
            return None

        data = result.get('data', {})

        # Extract required fields
        required_fields = [
            'livekit_url', 'livekit_api_key', 'livekit_api_secret',
            'access_key', 'secret_key', 'session_token',
            'bucket', 'region', 'key'
        ]

        # Validate all required fields are present
        missing_fields = [field for field in required_fields if not data.get(field)]
        if missing_fields:
            logger.error(f"Missing required credentials: {', '.join(missing_fields)}")
            return None

        # Create and return credentials object
        try:
            credentials = EgressCredentials(
                livekit_url=data['livekit_url'],
                livekit_api_key=data['livekit_api_key'],
                livekit_api_secret=data['livekit_api_secret'],
                access_key=data['access_key'],
                secret_key=data['secret_key'],
                session_token=data['session_token'],
                bucket=data['bucket'],
                region=data['region'],
                key=data['key']
            )
            logger.debug("Successfully fetched and validated egress credentials from Cekura")
            return credentials
        except Exception as e:
            logger.error(f"Failed to create credentials object: {e}")
            return None

    async def _inject_mock_tools(self, agent) -> None:
        """Inject mock tools into agent by replacing matching tools"""
        # Check if mock tools are enabled
        mock_tools_enabled = os.getenv("CEKURA_MOCK_TOOLS_ENABLED", "true").lower() in ("true", "1", "yes")
        if not mock_tools_enabled:
            logger.debug("Mock tools disabled via CEKURA_MOCK_TOOLS_ENABLED")
            return

        mock_tools_data = await self._fetch_mock_tools()
        if not mock_tools_data:
            logger.debug("No mock tools fetched, keeping original tools")
            return

        mock_tool_names = {tool_data.get("name") for tool_data in mock_tools_data}
        updated_tools = []

        for original_tool in agent.tools:
            tool_name = getattr(original_tool, '__name__', None)
            if tool_name in mock_tool_names:
                mock_data = next(t for t in mock_tools_data if t.get("name") == tool_name)
                # Preserve original tool's description
                original_description = getattr(original_tool, '__doc__', None)
                mock_tool = self._create_mock_tool(mock_data, original_description)
                updated_tools.append(mock_tool)
                logger.debug(f"Replaced tool '{tool_name}' with mock version")
            else:
                updated_tools.append(original_tool)

        await agent.update_tools(updated_tools)

    def _infer_type(self, value: Any) -> type:
        """Infer Python type from a value"""
        if isinstance(value, bool):
            return bool
        elif isinstance(value, int):
            return int
        elif isinstance(value, float):
            return float
        elif isinstance(value, str):
            return str
        elif isinstance(value, list):
            return list
        elif isinstance(value, dict):
            return dict
        else:
            return str  # Default to str for unknown types

    def _create_mock_tool(self, tool_data: dict, original_description: Optional[str] = None):
        """Create a mock tool function from Cekura tool data with dynamic signature"""
        tool_name = tool_data.get("name")
        # Use original description if provided, otherwise use description from mock data
        tool_description = original_description or tool_data.get("description", "")
        information = tool_data.get("information", [])

        if not information:
            logger.warning(f"No information provided for mock tool '{tool_name}'")
            # Return a simple tool that always returns error
            async def empty_mock(context: RunContext) -> dict[str, Any]:
                return {"error": "No mock data available"}
            return function_tool(empty_mock, name=tool_name, description=tool_description)

        # Extract parameter names and types from first input example
        first_input = information[0].get("input", {})
        param_specs = []
        for param_name, param_value in first_input.items():
            param_type = self._infer_type(param_value)
            param_specs.append((param_name, param_type))

        # Create closure to capture information data
        def create_mock_func(info_data):
            async def mock_func(context: RunContext, **kwargs) -> dict[str, Any]:
                # Match input with stored input/output pairs
                for pair in info_data:
                    input_data = pair.get("input", {})
                    # Check if all input fields match
                    if all(kwargs.get(k) == v for k, v in input_data.items()):
                        logger.info(f"Mock tool '{tool_name}' matched input, returning mock output")
                        return pair.get("output", {})
                logger.warning(f"Mock tool '{tool_name}' no match for input: {kwargs}")
                return {"error": "No matching mock data found"}
            return mock_func

        mock_func = create_mock_func(information)

        # Build proper signature with typed parameters
        sig_params = [inspect.Parameter('context', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=RunContext)]
        for param_name, param_type in param_specs:
            sig_params.append(
                inspect.Parameter(
                    param_name,
                    inspect.Parameter.KEYWORD_ONLY,
                    annotation=param_type
                )
            )

        # Set the signature and annotations on the function
        mock_func.__signature__ = inspect.Signature(sig_params)
        mock_func.__annotations__ = {
            'context': RunContext,
            **{name: ptype for name, ptype in param_specs},
            'return': dict[str, Any]
        }

        # Apply function_tool decorator
        return function_tool(
            mock_func,
            name=tool_name,
            description=tool_description
        )

    async def track_session(self, ctx, session, agent=None, **metadata) -> None:
        """
        Track simulation/test calls originating from the Cekura platform.

        Automatically collects transcripts, tool calls, metrics, and session metadata for
        analysis and debugging. If mock tools are configured in the Cekura dashboard, they
        will automatically replace matching tools in the agent.

        Environment variables:
            CEKURA_TRACING_ENABLED: Set to "false" to disable (default: "true")
            CEKURA_MOCK_TOOLS_ENABLED: Set to "false" to disable mock tools (default: "true")

        Args:
            ctx: LiveKit JobContext
            session: LiveKit AgentSession instance
            agent: Optional Agent instance to inject mock tools into
            **metadata: Additional custom metadata to include

        Example:
            await cekura.track_session(ctx, session, assistant)
        """
        # Do nothing if tracer is disabled or CEKURA_TRACING_ENABLED is false
        if not self.enabled:
            return

        tracing_enabled = os.getenv("CEKURA_TRACING_ENABLED", "true").lower() not in ("false", "0", "no")
        if not tracing_enabled:
            return

        session_id = str(uuid.uuid4())

        # Initialize session storage with simulation mode
        self.session_data[session_id] = {
            'start_time': time.time(),
            'metadata': metadata,
            'stt_metrics': [],
            'llm_metrics': [],
            'tts_metrics': [],
            'eou_metrics': [],
            'session_mode': 'simulation'
        }

        # Inject mock tools if agent is provided
        if agent:
            await self._inject_mock_tools(agent)

        # Setup session hooks
        self._setup_session_hooks(session_id, session, ctx)

        logger.info(f"Session tracking started: {session_id}")

    async def observe_session(self, ctx, session, **metadata) -> None:
        """
        Monitor live production calls with audio recording and metrics collection.

        Automatically starts dual-channel audio egress streaming to Cekura's storage along
        with transcripts, tool calls, metrics, and session metadata for monitoring and analysis.

        Environment variables:
            CEKURA_OBSERVABILITY_ENABLED: Set to "false" to disable (default: "true")

        Args:
            ctx: LiveKit JobContext
            session: LiveKit AgentSession instance
            **metadata: Additional custom metadata to include

        Example:
            await cekura.observe_session(ctx, session)
        """
        # Do nothing if tracer is disabled or CEKURA_OBSERVABILITY_ENABLED is false
        if not self.enabled:
            return

        observability_enabled = os.getenv("CEKURA_OBSERVABILITY_ENABLED", "true").lower() not in ("false", "0", "no")
        if not observability_enabled:
            return

        session_id = str(uuid.uuid4())

        # Initialize session storage with observability mode
        self.session_data[session_id] = {
            'start_time': time.time(),
            'metadata': metadata,
            'stt_metrics': [],
            'llm_metrics': [],
            'tts_metrics': [],
            'eou_metrics': [],
            'egress_s3_key': None,
            'egress_id': None,
            'egress_credentials': None,
            'session_mode': 'observability'
        }

        # Start egress recording - cleanup and return if it fails
        egress_started = await self._start_session_egress(session_id, ctx)
        if not egress_started:
            logger.error(f"Failed to start egress for session {session_id}, aborting observability")
            del self.session_data[session_id]
            return

        # Setup session hooks
        self._setup_session_hooks(session_id, session, ctx)

        logger.info(f"Session observability started: {session_id}")

    async def _start_session_egress(self, session_id: str, ctx) -> bool:
        """
        Start dual channel audio egress recording to S3 for a session.

        Args:
            session_id: Session ID
            ctx: LiveKit JobContext to get room information

        Returns:
            True if egress was started successfully, False otherwise
        """
        # Check if livekit-api is installed
        try:
            from livekit import api
        except ImportError:
            logger.error("livekit-api not installed, cannot start egress")
            return False

        # Get room name from context
        room_name = None
        if hasattr(ctx, 'room') and ctx.room:
            try:
                room_name = ctx.room.name
            except Exception:
                pass

        if not room_name:
            logger.warning(f"Cannot start egress: room name not available for session {session_id}")
            return False

        # Fetch credentials from Cekura
        credentials = await self._fetch_egress_credentials()
        if not credentials:
            return False

        lkapi = None
        try:
            # Initialize LiveKit API client
            lkapi = api.LiveKitAPI(
                url=credentials.livekit_url,
                api_key=credentials.livekit_api_key,
                api_secret=credentials.livekit_api_secret,
            )

            # Create egress request with dual channel separation
            request = api.RoomCompositeEgressRequest(
                room_name=room_name,
                audio_only=True,
                audio_mixing=api.AudioMixing.DUAL_CHANNEL_AGENT,
                file_outputs=[
                    api.EncodedFileOutput(
                        filepath=credentials.key,
                        s3=api.S3Upload(
                            access_key=credentials.access_key,
                            secret=credentials.secret_key,
                            session_token=credentials.session_token,
                            bucket=credentials.bucket,
                            region=credentials.region,
                            endpoint=f"https://s3.{credentials.region}.amazonaws.com",
                        ),
                    )
                ],
            )

            # Start the egress
            info = await lkapi.egress.start_room_composite_egress(request)
            egress_id = info.egress_id

            # Store S3 key, egress ID, and credentials in session data
            self.session_data[session_id]['egress_s3_key'] = credentials.key
            self.session_data[session_id]['egress_id'] = egress_id
            self.session_data[session_id]['egress_credentials'] = credentials
            logger.info(f"Dual channel egress started: {egress_id} for session {session_id}, room {room_name}, S3 key: {credentials.key}")
            return True

        except Exception as e:
            logger.error(f"Failed to start egress for session {session_id}: {e}")
            return False
        finally:
            if lkapi:
                await lkapi.aclose()

    async def _stop_egress(self, session_id: str, egress_id: str) -> None:
        """
        Stop egress recording for a session.

        Args:
            session_id: Session ID
            egress_id: Egress ID to stop
        """
        try:
            from livekit import api
        except ImportError:
            logger.error("livekit-api not installed, cannot stop egress")
            return

        if session_id not in self.session_data:
            return

        # Get stored credentials
        credentials = self.session_data[session_id].get('egress_credentials')
        if not credentials:
            logger.warning(f"No credentials found for session {session_id}, cannot stop egress")
            return

        lkapi = None
        try:
            # Initialize LiveKit API client
            lkapi = api.LiveKitAPI(
                url=credentials.livekit_url,
                api_key=credentials.livekit_api_key,
                api_secret=credentials.livekit_api_secret,
            )

            # Stop the egress
            request = api.StopEgressRequest(egress_id=egress_id)
            await lkapi.egress.stop_egress(request)
            logger.info(f"Egress stopped: {egress_id} for session {session_id}")

        except Exception as e:
            logger.error(f"Failed to stop egress {egress_id} for session {session_id}: {e}")
        finally:
            if lkapi:
                await lkapi.aclose()

    def _collect_metrics(self, session_id: str, metrics):
        """Collect metrics from LiveKit events"""
        if session_id not in self.session_data:
            return

        data = self.session_data[session_id]
        metrics_type = type(metrics).__name__

        # Categorize by type
        if 'STT' in metrics_type:
            data['stt_metrics'].append(self._serialize_metrics(metrics))
        elif 'LLM' in metrics_type:
            data['llm_metrics'].append(self._serialize_metrics(metrics))
        elif 'TTS' in metrics_type:
            data['tts_metrics'].append(self._serialize_metrics(metrics))
        elif 'EOU' in metrics_type:
            data['eou_metrics'].append(self._serialize_metrics(metrics))

    def _serialize_metrics(self, metrics) -> Dict[str, Any]:
        """Convert metrics object to dictionary"""
        result = {}

        # Common attributes across all metrics types
        common_attrs = [
            'timestamp', 'duration', 'audio_duration', 'streamed',
            'prompt_tokens', 'completion_tokens', 'cached_tokens', 'total_tokens',
            'tokens_per_second', 'ttft', 'speech_id',
            'characters_count', 'ttfb',
            'end_of_utterance_delay', 'transcription_delay', 'on_user_turn_completed_delay'
        ]

        for attr in common_attrs:
            if hasattr(metrics, attr):
                value = getattr(metrics, attr)
                if value is not None:
                    result[attr] = value

        return result

    def _safe_json_value(self, value: Any, max_depth: int = 10, current_depth: int = 0) -> Any:
        """
        Recursively convert a value to a JSON-serializable format.
        Non-serializable objects are converted to strings or skipped.

        Args:
            value: Value to serialize
            max_depth: Maximum recursion depth to prevent infinite loops
            current_depth: Current recursion depth

        Returns:
            JSON-serializable value or None if not serializable
        """
        # Prevent infinite recursion
        if current_depth >= max_depth:
            return str(value)[:100]

        # Handle None and primitive types
        if value is None or isinstance(value, (str, int, float, bool)):
            return value

        # Handle lists
        if isinstance(value, (list, tuple)):
            result = []
            for item in value:
                try:
                    serialized = self._safe_json_value(item, max_depth, current_depth + 1)
                    if serialized is not None:
                        result.append(serialized)
                except Exception:
                    # Skip items that can't be serialized
                    continue
            return result

        # Handle dictionaries
        if isinstance(value, dict):
            result = {}
            for key, val in value.items():
                try:
                    # Convert non-string keys to strings
                    str_key = str(key) if not isinstance(key, str) else key
                    serialized = self._safe_json_value(val, max_depth, current_depth + 1)
                    if serialized is not None:
                        result[str_key] = serialized
                except Exception:
                    # Skip key-value pairs that can't be serialized
                    continue
            return result

        # For all other objects, try converting to string with length limit
        try:
            return str(value)[:1000]
        except Exception:
            # If even string conversion fails, return None (will be skipped)
            return None

    def _dump_object_attributes(self, obj) -> Dict[str, Any]:
        """Dump all attributes of an object, safely handling async properties and protobuf objects"""
        result = {}

        # Get all attributes
        for attr_name in dir(obj):
            if attr_name.startswith('_') or attr_name in ('DESCRIPTOR', 'sid'):
                continue
            try:
                # Check if it's a property or descriptor before accessing
                attr_descriptor = getattr(type(obj), attr_name, None)

                # Skip async properties and coroutine functions
                if inspect.iscoroutinefunction(attr_descriptor):
                    continue

                # Check if it's a coroutine property before accessing
                try:
                    attr_value = getattr(obj, attr_name)
                except Exception:
                    # Skip attributes that raise errors when accessed
                    continue

                # Skip methods, coroutines, and other callables
                if callable(attr_value) or inspect.iscoroutine(attr_value):
                    continue

                # Use safe JSON serialization for all values
                serialized_value = self._safe_json_value(attr_value)
                if serialized_value is not None:
                    result[attr_name] = serialized_value
            except Exception:
                # Silently skip attributes that cause errors (including async properties)
                continue

        return result

    async def _send_webhook(self, session_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
        """Send session data to Cekura webhook"""
        result = await self._make_api_request('POST', WEBHOOK_ENDPOINT, json_data=payload)

        if result.get('success'):
            logger.info(f"Session data sent successfully (status {result.get('status_code')})")
        else:
            logger.error(f"Failed to send session data: {result.get('error')}")

        return result

    async def _send_observability(self, session_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
        """Send observability data to Cekura API"""
        # Add egress_s3_key to payload for observability endpoint
        egress_s3_key = self.session_data[session_id].get('egress_s3_key')
        observability_payload = {**payload, 'egress_s3_key': egress_s3_key}

        result = await self._make_api_request('POST', OBSERVABILITY_ENDPOINT, json_data=observability_payload)

        if result.get('success'):
            logger.info(f"Observability data sent successfully (status {result.get('status_code')})")
        else:
            logger.error(f"Failed to send observability data: {result.get('error')}")

        return result

    async def _export(self, session_id: str, ctx) -> Dict[str, Any]:
        """
        Export session data at the end of a call.

        Args:
            session_id: Session ID from track_session()
            ctx: LiveKit JobContext to extract session report

        Returns:
            Result dictionary with success status
        """
        from cekura import __version__

        if session_id not in self.session_data:
            logger.error(f"Session {session_id} not found")
            return {"success": False, "error": "Session not found"}

        data = self.session_data[session_id]
        end_time = time.time()

        # Get structured session report from LiveKit
        try:
            report = ctx.make_session_report()
            report_dict = report.to_dict()
        except Exception as e:
            logger.warning(f"Could not get session report: {e}")
            report_dict = {}

        # Collect room information and get SID
        room_sid = None
        room_info = {}
        if hasattr(ctx, 'room') and ctx.room:
            # Get room SID first (before dumping attributes to avoid triggering coroutine)
            try:
                room_sid = await ctx.room.sid
            except Exception:
                pass

            room_info = self._dump_object_attributes(ctx.room)

        job_info = {}
        if hasattr(ctx, 'job') and ctx.job:
            job_info = self._dump_object_attributes(ctx.job)

        # Build payload with provider-specific data grouped
        payload = {
            # Basic session info (root level)
            "agent_id": self.agent_id,
            "session_id": room_sid,
            "session_start": data['start_time'],
            "session_export": end_time,
            "sdk_version": __version__,

            # Complete session report from LiveKit (authoritative source)
            # This includes:
            # - chat_history.items: complete conversation with tool calls
            # - events: all state/conversation events (NOT metrics!)
            # - options: session configuration
            # - job_id, room_id, timestamps
            "session_report": report_dict,

            # LiveKit provider-specific data
            "provider_data": {
                "session_id": room_sid,

                # Room and Job details (all attributes, flattened)
                "room_info": room_info,
                "job_info": job_info,

                # Raw per-request metrics (backend will aggregate)
                "raw_metrics": {
                    "stt": data['stt_metrics'],
                    "llm": data['llm_metrics'],
                    "tts": data['tts_metrics'],
                    "eou": data['eou_metrics']
                },

                # Custom metadata from user
                "custom_metadata": data['metadata']
            }
        }

        # Log summary of captured data
        chat_history = report_dict.get('chat_history', {}).get('items', [])
        events = report_dict.get('events', [])
        tool_calls_count = len([item for item in chat_history if item.get('type') == 'function_call'])
        duration_seconds = int(end_time - data['start_time'])

        logger.info(f"Exporting session {session_id}: {duration_seconds}s duration, "
                   f"{len(chat_history)} messages, {tool_calls_count} tool calls")
        logger.debug(f"Session metrics: STT={len(data['stt_metrics'])}, "
                    f"LLM={len(data['llm_metrics'])}, TTS={len(data['tts_metrics'])}, "
                    f"events={len(events)}")

        # Send to appropriate endpoint based on session mode
        session_mode = data.get('session_mode', 'simulation')

        if session_mode == 'simulation':
            # Send to webhook for simulation calls
            await self._send_webhook(session_id, payload)
        else:
            # Send to observability endpoint for production calls
            await self._send_observability(session_id, payload)

        # Cleanup session data
        del self.session_data[session_id]
        logger.debug(f"Session data cleaned up: {session_id}")
