"""
Pipecat observer handler for Aigie SDK.

Implements BaseObserver to automatically trace Pipecat pipeline executions,
including conversations, turns, STT/TTS/LLM calls, and interruptions.

Provides comprehensive voice metrics tracking including TTFB and latency.
"""

import logging
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, TYPE_CHECKING

from ...buffer import EventType
from ...cost_tracking import UsageMetadata, calculate_cost
from .config import PipecatConfig
from .metrics import VoiceMetrics, MetricsAggregator
from .error_detection import VoiceErrorDetector

logger = logging.getLogger(__name__)


def _utc_now() -> datetime:
    """Get current time in UTC with timezone info."""
    return datetime.now(timezone.utc)


def _utc_isoformat() -> str:
    """Get current UTC time as ISO format string."""
    return datetime.now(timezone.utc).isoformat()


if TYPE_CHECKING:
    try:
        from pipecat.observers.base_observer import BaseObserver, FramePushed
    except ImportError:
        pass


class AigieObserver:
    """
    Aigie observer for Pipecat pipeline monitoring.

    Non-intrusive - observes frame flow without blocking the pipeline.
    Implements the BaseObserver pattern from Pipecat.

    Trace Hierarchy:
        TRACE: Voice Conversation
        ├── SPAN: Turn 1 (type=turn)
        │   ├── SPAN: STT - "Hello, how can I help?"
        │   ├── SPAN: LLM - 150 tokens
        │   └── SPAN: TTS - 2.3s audio
        ├── SPAN: Turn 2 (type=turn)
        │   ├── SPAN: STT - "What's the weather?"
        │   ├── SPAN: LLM - 200 tokens
        │   └── SPAN: TTS - 3.1s audio

    Example:
        >>> from pipecat.pipeline.task import PipelineTask
        >>> from aigie.integrations.pipecat import AigieObserver
        >>>
        >>> observer = AigieObserver()
        >>> task = PipelineTask(pipeline, observers=[observer])
        >>> await task.run()
    """

    def __init__(
        self,
        config: Optional[PipecatConfig] = None,
        trace_name: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
        tags: Optional[List[str]] = None,
        user_id: Optional[str] = None,
        session_id: Optional[str] = None,
    ):
        """
        Initialize Pipecat observer.

        Args:
            config: Configuration for tracing behavior
            trace_name: Name for the trace (default: "Voice Conversation")
            metadata: Additional metadata to attach
            tags: Tags to apply to trace and spans
            user_id: User ID for the trace
            session_id: Session ID for the trace
        """
        self.config = config or PipecatConfig.from_env()
        self.trace_name = trace_name or "Voice Conversation"
        self.metadata = metadata or {}
        self.tags = tags or []
        self.user_id = user_id
        self.session_id = session_id

        # State tracking
        self.trace_id: Optional[str] = None
        self.conversation_span_id: Optional[str] = None
        self.current_turn_span_id: Optional[str] = None
        self.current_stt_span_id: Optional[str] = None
        self.current_llm_span_id: Optional[str] = None
        self.current_tts_span_id: Optional[str] = None

        # Timing state for TTFB calculations
        self._stt_start_time: Optional[datetime] = None
        self._llm_start_time: Optional[datetime] = None
        self._tts_start_time: Optional[datetime] = None
        self._turn_start_time: Optional[datetime] = None
        self._conversation_start_time: Optional[datetime] = None
        self._user_stopped_speaking_time: Optional[datetime] = None

        # Track if we've seen first results (for TTFB)
        self._stt_first_result: bool = False
        self._llm_first_token: bool = False
        self._tts_first_audio: bool = False

        # Service call tracking maps for concurrent calls
        self._stt_calls: Dict[str, Dict[str, Any]] = {}
        self._llm_calls: Dict[str, Dict[str, Any]] = {}
        self._tts_calls: Dict[str, Dict[str, Any]] = {}

        # Metrics tracking
        self._current_turn_metrics: Optional[VoiceMetrics] = None
        self._metrics_aggregator = MetricsAggregator()

        # Error detection
        self._error_detector = VoiceErrorDetector()
        self._has_errors = False
        self._error_messages: List[str] = []

        # Turn tracking
        self._turn_count = 0
        self._in_turn = False

        # Transcription accumulator for the current turn
        self._current_transcription = ""
        self._current_bot_response = ""

        # Aigie client (lazy loaded)
        self._aigie = None

    def _get_aigie(self):
        """Lazy load Aigie client."""
        if self._aigie is None:
            from ...client import get_aigie
            self._aigie = get_aigie()
        return self._aigie

    # ========================================================================
    # BaseObserver Interface
    # ========================================================================

    async def on_push_frame(self, data: "FramePushed") -> None:
        """
        Route frames to specific handlers.

        This is the main entry point called by Pipecat for each frame.
        """
        if not self.config.enabled:
            return

        frame = data.frame
        frame_class = type(frame).__name__

        # Dynamically route based on frame type name
        # This avoids import issues when pipecat isn't installed
        handler_map = {
            # Conversation lifecycle
            "StartFrame": self._handle_start_frame,
            "EndFrame": self._handle_end_frame,
            "CancelFrame": self._handle_cancel_frame,

            # Turn tracking
            "UserStartedSpeakingFrame": self._handle_user_started_speaking,
            "UserStoppedSpeakingFrame": self._handle_user_stopped_speaking,
            "BotStartedSpeakingFrame": self._handle_bot_started_speaking,
            "BotStoppedSpeakingFrame": self._handle_bot_stopped_speaking,
            "InterruptionFrame": self._handle_interruption,

            # STT frames
            "TranscriptionFrame": self._handle_transcription,
            "InterimTranscriptionFrame": self._handle_interim_transcription,

            # LLM frames
            "LLMFullResponseStartFrame": self._handle_llm_start,
            "LLMFullResponseEndFrame": self._handle_llm_end,
            "TextFrame": self._handle_text_frame,
            "LLMTextFrame": self._handle_llm_text,

            # TTS frames
            "TTSStartedFrame": self._handle_tts_started,
            "TTSStoppedFrame": self._handle_tts_stopped,
            "TTSAudioRawFrame": self._handle_tts_audio,
            "AudioRawFrame": self._handle_audio_frame,

            # Metrics frames
            "MetricsFrame": self._handle_metrics,
            "TTFBMetricsFrame": self._handle_ttfb_metrics,
            "ProcessingMetricsFrame": self._handle_processing_metrics,

            # Error frames
            "ErrorFrame": self._handle_error,
        }

        handler = handler_map.get(frame_class)
        if handler:
            try:
                await handler(data)
            except Exception as e:
                logger.warning(f"[AIGIE] Error handling {frame_class}: {e}")

    # ========================================================================
    # Conversation Lifecycle Handlers
    # ========================================================================

    async def _handle_start_frame(self, data: "FramePushed") -> None:
        """Handle StartFrame - create trace and conversation span."""
        if not self.config.trace_conversations:
            return

        aigie = self._get_aigie()
        if not aigie or not aigie._initialized:
            return

        try:
            self.trace_id = str(uuid.uuid4())
            self._conversation_start_time = _utc_now()

            # Reset state for new conversation
            self._reset_conversation_state()

            # Create trace
            trace_data = {
                "id": self.trace_id,
                "name": self.trace_name,
                "metadata": {
                    "framework": "pipecat",
                    "type": "voice_conversation",
                    **self.metadata,
                },
                "tags": self.tags,
                "start_time": self._conversation_start_time.isoformat(),
            }

            if self.user_id:
                trace_data["user_id"] = self.user_id
            if self.session_id:
                trace_data["session_id"] = self.session_id

            await aigie._buffer.add(EventType.TRACE_CREATE, trace_data)
            await aigie._buffer.flush()

            # Create Conversation span as a wrapper for the entire conversation
            # Use "workflow" type which is a valid SpanType
            self.conversation_span_id = str(uuid.uuid4())
            conv_span_data = {
                "id": self.conversation_span_id,
                "trace_id": self.trace_id,
                "parent_id": None,  # Direct child of trace root
                "name": "Conversation",
                "type": "workflow",  # Valid SpanType
                "start_time": self._conversation_start_time.isoformat(),
                "metadata": {
                    "framework": "pipecat",
                    "span_category": "conversation",
                },
                "tags": self.tags,
            }
            await aigie._buffer.add(EventType.SPAN_CREATE, conv_span_data)

            logger.debug(f"[AIGIE] Conversation started: {self.trace_id}")

        except Exception as e:
            logger.warning(f"[AIGIE] Error in _handle_start_frame: {e}")

    async def _handle_end_frame(self, data: "FramePushed") -> None:
        """Handle EndFrame - complete conversation span and trace."""
        await self._complete_conversation(status="success")

    async def _handle_cancel_frame(self, data: "FramePushed") -> None:
        """Handle CancelFrame - complete conversation with cancelled status."""
        await self._complete_conversation(status="cancelled")

    async def _complete_conversation(self, status: str = "success") -> None:
        """Complete the conversation trace and spans."""
        if not self.trace_id:
            return

        aigie = self._get_aigie()
        if not aigie or not aigie._initialized:
            return

        try:
            end_time = _utc_now()

            # Complete any pending turn
            if self._in_turn:
                await self._complete_turn()

            # Complete conversation span
            if self.conversation_span_id:
                duration_ms = 0
                if self._conversation_start_time:
                    duration_ms = (end_time - self._conversation_start_time).total_seconds() * 1000

                # Get aggregated metrics
                metrics = self._metrics_aggregator.to_dict()

                update_data = {
                    "id": self.conversation_span_id,
                    "trace_id": self.trace_id,
                    "end_time": end_time.isoformat(),
                    "duration_ns": int(duration_ms * 1_000_000),
                    "status": status,
                    "is_error": status == "error" or self._has_errors,
                    "metadata": {
                        "turn_count": self._turn_count,
                        "duration_ms": duration_ms,
                        **metrics,
                    },
                }

                if self._error_messages:
                    update_data["error"] = "; ".join(self._error_messages[:3])

                await aigie._buffer.add(EventType.SPAN_UPDATE, update_data)

            # Update trace with final metrics
            trace_update = {
                "id": self.trace_id,
                "status": status if not self._has_errors else "error",
                "end_time": end_time.isoformat(),
                "metadata": {
                    "turn_count": self._turn_count,
                    "interruption_count": self._metrics_aggregator.interruption_count,
                    "total_input_tokens": self._metrics_aggregator.total_input_tokens,
                    "total_output_tokens": self._metrics_aggregator.total_output_tokens,
                    "avg_user_bot_latency_ms": self._metrics_aggregator.avg_user_bot_latency_ms,
                    "error_count": self._error_detector.stats.total_errors,
                },
                "total_tokens": self._metrics_aggregator.total_input_tokens + self._metrics_aggregator.total_output_tokens,
                "prompt_tokens": self._metrics_aggregator.total_input_tokens,
                "completion_tokens": self._metrics_aggregator.total_output_tokens,
            }

            if self._error_messages:
                trace_update["error"] = "; ".join(self._error_messages[:3])

            await aigie._buffer.add(EventType.TRACE_UPDATE, trace_update)

            logger.debug(f"[AIGIE] Conversation completed: {self.trace_id} (turns={self._turn_count})")

        except Exception as e:
            logger.warning(f"[AIGIE] Error completing conversation: {e}")

    # ========================================================================
    # Turn Tracking Handlers
    # ========================================================================

    async def _handle_user_started_speaking(self, data: "FramePushed") -> None:
        """Handle UserStartedSpeakingFrame - start a new turn."""
        if not self.config.trace_turns:
            return

        aigie = self._get_aigie()
        if not aigie or not aigie._initialized or not self.trace_id:
            return

        try:
            # Start new turn
            self._turn_count += 1
            self._in_turn = True
            self._turn_start_time = _utc_now()
            self._current_turn_metrics = VoiceMetrics()
            self._current_turn_metrics.record_user_started_speaking()
            self._current_transcription = ""
            self._current_bot_response = ""

            # Reset TTFB tracking for new turn
            self._stt_first_result = False
            self._llm_first_token = False
            self._tts_first_audio = False

            # Create turn span at start so child spans can reference it
            # We'll update it with full data when the turn completes
            # Use "chain" type which is a valid SpanType (turn is not in the enum)
            #
            # NOTE: Turn spans have parent_id=None (direct children of trace root)
            # This creates a FLAT/LINEAR structure in the Flow View:
            #   Conversation → Turn 1 → Turn 2 → Turn 3
            # Instead of nested structure where all turns are inside Conversation box.
            self.current_turn_span_id = str(uuid.uuid4())
            span_data = {
                "id": self.current_turn_span_id,
                "trace_id": self.trace_id,
                "parent_id": None,  # Direct child of trace for linear Flow View
                "name": f"Turn {self._turn_count}",
                "type": "chain",  # Valid SpanType - a turn is a chain of operations
                "start_time": self._turn_start_time.isoformat(),
                "metadata": {
                    "turn_number": self._turn_count,
                    "span_category": "turn",  # Keep original type in metadata
                    "conversation_id": self.conversation_span_id,  # Link to conversation
                },
                "tags": self.tags,
                # Note: Don't set status here - will be set to "success" when turn completes
            }

            await aigie._buffer.add(EventType.SPAN_CREATE, span_data)

            logger.debug(f"[AIGIE] Turn {self._turn_count} started")

        except Exception as e:
            logger.warning(f"[AIGIE] Error in _handle_user_started_speaking: {e}")

    async def _handle_user_stopped_speaking(self, data: "FramePushed") -> None:
        """Handle UserStoppedSpeakingFrame - record user speech end time."""
        if self._current_turn_metrics:
            self._current_turn_metrics.record_user_stopped_speaking()
            self._user_stopped_speaking_time = _utc_now()

    async def _handle_bot_started_speaking(self, data: "FramePushed") -> None:
        """Handle BotStartedSpeakingFrame - record bot speech start (for latency)."""
        if self._current_turn_metrics:
            self._current_turn_metrics.record_bot_started_speaking()

    async def _handle_bot_stopped_speaking(self, data: "FramePushed") -> None:
        """Handle BotStoppedSpeakingFrame - complete the turn."""
        if self._current_turn_metrics:
            self._current_turn_metrics.record_bot_stopped_speaking()

        # Complete the turn
        await self._complete_turn()

    async def _handle_interruption(self, data: "FramePushed") -> None:
        """Handle InterruptionFrame - record interruption event."""
        if not self.config.capture_interruptions:
            return

        aigie = self._get_aigie()
        if not aigie or not aigie._initialized or not self.trace_id:
            return

        try:
            self._metrics_aggregator.record_interruption()

            # Create interruption event span
            # Use "guardrail" type which is a valid SpanType (event is not in the enum)
            span_id = str(uuid.uuid4())
            timestamp = _utc_now()

            span_data = {
                "id": span_id,
                "trace_id": self.trace_id,
                "parent_id": self.current_turn_span_id or self.conversation_span_id,
                "name": "Interruption",
                "type": "guardrail",  # Valid SpanType - interruption is like a guardrail event
                "start_time": timestamp.isoformat(),
                "end_time": timestamp.isoformat(),
                "duration_ns": 0,
                "metadata": {
                    "interruption": True,
                    "turn_number": self._turn_count,
                    "span_category": "interruption",  # Keep original type in metadata
                },
                # Use "error" status for interruptions (valid SpanStatus: success, failure, error)
                "status": "error",
                "level": "WARNING",
                "status_message": "User interrupted the bot response",
            }

            await aigie._buffer.add(EventType.SPAN_CREATE, span_data)

            logger.debug(f"[AIGIE] Interruption recorded in turn {self._turn_count}")

        except Exception as e:
            logger.warning(f"[AIGIE] Error in _handle_interruption: {e}")

    async def _complete_turn(self) -> None:
        """Complete the current turn span."""
        if not self._in_turn or not self.current_turn_span_id or not self._turn_start_time:
            return

        aigie = self._get_aigie()
        if not aigie or not aigie._initialized or not self.trace_id:
            return

        try:
            end_time = _utc_now()
            duration_ms = (end_time - self._turn_start_time).total_seconds() * 1000

            # Record turn metrics
            if self._current_turn_metrics:
                self._metrics_aggregator.record_turn(self._current_turn_metrics)

            # Prepare turn metadata
            turn_metadata = {
                "turn_number": self._turn_count,
                "duration_ms": duration_ms,
            }

            if self._current_turn_metrics:
                turn_metadata.update(self._current_turn_metrics.to_dict())

            if self.config.capture_transcriptions and self._current_transcription:
                transcription = self._current_transcription
                if len(transcription) > self.config.max_transcription_length:
                    transcription = transcription[:self.config.max_transcription_length] + "..."
                turn_metadata["user_transcription"] = transcription

            if self.config.capture_bot_responses and self._current_bot_response:
                response = self._current_bot_response
                if len(response) > self.config.max_transcription_length:
                    response = response[:self.config.max_transcription_length] + "..."
                turn_metadata["bot_response"] = response

            # Update the turn span with completion data
            # Note: Backend merge issues may cause some fields not to appear
            update_data = {
                "id": self.current_turn_span_id,
                "trace_id": self.trace_id,
                "end_time": end_time.isoformat(),
                "duration_ns": int(duration_ms * 1_000_000),
                "status": "success",
                "metadata": turn_metadata,
            }

            if self._current_transcription:
                update_data["input"] = self._current_transcription[:self.config.max_transcription_length]
            if self._current_bot_response:
                update_data["output"] = self._current_bot_response[:self.config.max_transcription_length]

            await aigie._buffer.add(EventType.SPAN_UPDATE, update_data)

            logger.debug(f"[AIGIE] Turn {self._turn_count} completed (duration={duration_ms:.0f}ms)")

        except Exception as e:
            logger.warning(f"[AIGIE] Error completing turn: {e}")
        finally:
            self._in_turn = False
            self.current_turn_span_id = None
            self._current_turn_metrics = None

    # ========================================================================
    # STT Handlers
    # ========================================================================

    async def _handle_transcription(self, data: "FramePushed") -> None:
        """Handle TranscriptionFrame - final transcription result."""
        if not self.config.trace_stt:
            return

        aigie = self._get_aigie()
        if not aigie or not aigie._initialized or not self.trace_id:
            return

        try:
            frame = data.frame
            text = getattr(frame, 'text', '') or ''

            # Accumulate transcription
            if text:
                if self._current_transcription:
                    self._current_transcription += " " + text
                else:
                    self._current_transcription = text

            # Record TTFB if this is first result
            if not self._stt_first_result and self._current_turn_metrics:
                self._current_turn_metrics.record_stt_first_result()
                self._stt_first_result = True

            # Create STT span
            # Use "tool" type which is a valid SpanType (stt is not in the enum)
            span_id = str(uuid.uuid4())
            timestamp = _utc_now()

            span_data = {
                "id": span_id,
                "trace_id": self.trace_id,
                "parent_id": self.current_turn_span_id or self.conversation_span_id,
                "name": "STT",
                "type": "tool",  # Valid SpanType - STT is a tool call
                "start_time": timestamp.isoformat(),
                "end_time": timestamp.isoformat(),
                "duration_ns": 0,
                "metadata": {
                    "service": "stt",
                    "span_category": "stt",  # Keep original type in metadata
                },
                "status": "success",
            }

            # Include model name if configured
            if self.config.stt_model:
                span_data["model"] = self.config.stt_model
                span_data["metadata"]["model"] = self.config.stt_model

            if self.config.capture_transcriptions and text:
                span_data["output"] = text[:self.config.max_transcription_length]
                span_data["metadata"]["transcription"] = text[:self.config.max_transcription_length]

            # Include TTFB if available
            if self._current_turn_metrics and self._current_turn_metrics.stt_ttfb_ms:
                span_data["metadata"]["ttfb_ms"] = self._current_turn_metrics.stt_ttfb_ms

            await aigie._buffer.add(EventType.SPAN_CREATE, span_data)

        except Exception as e:
            logger.warning(f"[AIGIE] Error in _handle_transcription: {e}")

    async def _handle_interim_transcription(self, data: "FramePushed") -> None:
        """Handle InterimTranscriptionFrame - partial transcription."""
        # For interim results, we just track TTFB
        if not self._stt_first_result and self._current_turn_metrics:
            self._current_turn_metrics.record_stt_first_result()
            self._stt_first_result = True

    # ========================================================================
    # LLM Handlers
    # ========================================================================

    async def _handle_llm_start(self, data: "FramePushed") -> None:
        """Handle LLMFullResponseStartFrame - LLM processing started."""
        if not self.config.trace_llm:
            return

        # Just record start time and generate span ID - we'll create the span at end
        # This works around backend merge issues where SPAN_UPDATE doesn't merge properly
        self._llm_start_time = _utc_now()
        self.current_llm_span_id = str(uuid.uuid4())

        if self._current_turn_metrics:
            self._current_turn_metrics.record_llm_start()

    async def _handle_llm_end(self, data: "FramePushed") -> None:
        """Handle LLMFullResponseEndFrame - LLM processing completed."""
        if not self.current_llm_span_id or not self._llm_start_time:
            return

        aigie = self._get_aigie()
        if not aigie or not aigie._initialized or not self.trace_id:
            return

        try:
            end_time = _utc_now()
            duration_ms = (end_time - self._llm_start_time).total_seconds() * 1000

            # Extract token usage if available from the frame
            frame = data.frame
            input_tokens = getattr(frame, 'input_tokens', 0) or 0
            output_tokens = getattr(frame, 'output_tokens', 0) or 0

            if self._current_turn_metrics:
                self._current_turn_metrics.record_llm_end(input_tokens, output_tokens)

            # Calculate total tokens
            total_tokens = input_tokens + output_tokens

            # Calculate cost if model is configured
            input_cost = 0.0
            output_cost = 0.0
            total_cost = 0.0
            if self.config.llm_model and (input_tokens > 0 or output_tokens > 0):
                usage = UsageMetadata(
                    input_tokens=input_tokens,
                    output_tokens=output_tokens,
                    total_tokens=total_tokens,
                    model=self.config.llm_model,
                )
                cost_breakdown = calculate_cost(usage)
                if cost_breakdown:
                    input_cost = float(cost_breakdown.input_cost)
                    output_cost = float(cost_breakdown.output_cost)
                    total_cost = float(cost_breakdown.total_cost)

            # Build metadata with token usage in the format backend expects
            # Backend expects prompt_tokens/completion_tokens (not input_tokens/output_tokens)
            metadata = {
                "service": "llm",
                "duration_ms": duration_ms,
                # Store tokens with both naming conventions for compatibility
                "input_tokens": input_tokens,
                "output_tokens": output_tokens,
                "prompt_tokens": input_tokens,  # Backend expects this name
                "completion_tokens": output_tokens,  # Backend expects this name
                "total_tokens": total_tokens,
                # Cost information
                "input_cost": input_cost,
                "output_cost": output_cost,
                "total_cost": total_cost,
                "cost": total_cost,  # Some backends use 'cost' directly
                # Token usage nested object for some backends
                "token_usage": {
                    "prompt_tokens": input_tokens,
                    "completion_tokens": output_tokens,
                    "total_tokens": total_tokens,
                    "input_tokens": input_tokens,
                    "output_tokens": output_tokens,
                    "input_cost": input_cost,
                    "output_cost": output_cost,
                    "total_cost": total_cost,
                },
            }

            # Include model in metadata
            if self.config.llm_model:
                metadata["model"] = self.config.llm_model

            # Include TTFB if available
            if self._current_turn_metrics and self._current_turn_metrics.llm_ttfb_ms:
                metadata["ttfb_ms"] = self._current_turn_metrics.llm_ttfb_ms

            # Create COMPLETE span with all data at once (workaround for backend merge issues)
            # NOTE: Top-level token fields (prompt_tokens, completion_tokens, total_tokens)
            # cause LLM spans to not appear in the UI, so we only put them in metadata
            # However, token_usage as an object might work (matching span.py pattern)
            span_data = {
                "id": self.current_llm_span_id,
                "trace_id": self.trace_id,
                "parent_id": self.current_turn_span_id or self.conversation_span_id,
                "name": "LLM",
                "type": "llm",
                "start_time": self._llm_start_time.isoformat(),
                "end_time": end_time.isoformat(),
                "duration_ns": int(duration_ms * 1_000_000),
                "status": "success",
                "metadata": metadata,
                # Token usage as object (matching span.py format)
                "token_usage": {
                    "prompt_tokens": input_tokens,
                    "completion_tokens": output_tokens,
                    "total_tokens": total_tokens,
                    "unit": "TOKENS",
                    "input_cost": input_cost,
                    "output_cost": output_cost,
                    "total_cost": total_cost,
                },
                # Also use the 'usage' key that some backends expect
                "usage": {
                    "prompt_tokens": input_tokens,
                    "completion_tokens": output_tokens,
                    "total_tokens": total_tokens,
                    "input_tokens": input_tokens,
                    "output_tokens": output_tokens,
                    "input_cost": input_cost,
                    "output_cost": output_cost,
                    "total_cost": total_cost,
                },
                # Top-level cost fields
                "input_cost": input_cost,
                "output_cost": output_cost,
                "total_cost": total_cost,
            }

            # Include model at top level
            if self.config.llm_model:
                span_data["model"] = self.config.llm_model

            # Include input (user transcription)
            if self._current_transcription:
                span_data["input"] = self._current_transcription[:self.config.max_transcription_length]

            await aigie._buffer.add(EventType.SPAN_CREATE, span_data)

            logger.debug(f"[AIGIE] LLM completed (duration={duration_ms:.0f}ms, tokens={input_tokens + output_tokens})")

        except Exception as e:
            logger.warning(f"[AIGIE] Error in _handle_llm_end: {e}")
        finally:
            self.current_llm_span_id = None
            self._llm_start_time = None

    async def _handle_text_frame(self, data: "FramePushed") -> None:
        """Handle TextFrame - text output (may be from LLM)."""
        frame = data.frame
        text = getattr(frame, 'text', '') or ''

        if text:
            self._current_bot_response += text

    async def _handle_llm_text(self, data: "FramePushed") -> None:
        """Handle LLMTextFrame - streaming LLM text."""
        # Record TTFB on first token
        if not self._llm_first_token and self._current_turn_metrics:
            self._current_turn_metrics.record_llm_first_token()
            self._llm_first_token = True

        frame = data.frame
        text = getattr(frame, 'text', '') or ''
        if text:
            self._current_bot_response += text

    # ========================================================================
    # TTS Handlers
    # ========================================================================

    async def _handle_tts_started(self, data: "FramePushed") -> None:
        """Handle TTSStartedFrame - TTS processing started."""
        if not self.config.trace_tts:
            return

        # Just record start time and generate span ID - we'll create the span at end
        self._tts_start_time = _utc_now()
        self.current_tts_span_id = str(uuid.uuid4())

        if self._current_turn_metrics:
            self._current_turn_metrics.record_tts_start()

    async def _handle_tts_stopped(self, data: "FramePushed") -> None:
        """Handle TTSStoppedFrame - TTS processing completed."""
        if not self.current_tts_span_id or not self._tts_start_time:
            return

        aigie = self._get_aigie()
        if not aigie or not aigie._initialized or not self.trace_id:
            return

        try:
            end_time = _utc_now()
            duration_ms = (end_time - self._tts_start_time).total_seconds() * 1000

            # Get character count from bot response
            characters = len(self._current_bot_response)
            if self._current_turn_metrics:
                self._current_turn_metrics.record_tts_end(characters)

            # Build metadata
            metadata = {
                "service": "tts",
                "duration_ms": duration_ms,
                "characters": characters,
            }

            # Include model in metadata
            if self.config.tts_model:
                metadata["model"] = self.config.tts_model

            # Include TTFB if available
            if self._current_turn_metrics and self._current_turn_metrics.tts_ttfb_ms:
                metadata["ttfb_ms"] = self._current_turn_metrics.tts_ttfb_ms

            # Create COMPLETE span with all data at once
            # Use "tool" type which is a valid SpanType (tts is not in the enum)
            span_data = {
                "id": self.current_tts_span_id,
                "trace_id": self.trace_id,
                "parent_id": self.current_turn_span_id or self.conversation_span_id,
                "name": "TTS",
                "type": "tool",  # Valid SpanType - TTS is a tool call
                "start_time": self._tts_start_time.isoformat(),
                "end_time": end_time.isoformat(),
                "duration_ns": int(duration_ms * 1_000_000),
                "status": "success",
                "metadata": metadata,
            }

            # Include model at top level
            if self.config.tts_model:
                span_data["model"] = self.config.tts_model

            # Include the bot response (text to be synthesized) as input
            if self._current_bot_response:
                span_data["input"] = self._current_bot_response[:self.config.max_transcription_length]

            await aigie._buffer.add(EventType.SPAN_CREATE, span_data)

            logger.debug(f"[AIGIE] TTS completed (duration={duration_ms:.0f}ms, chars={characters})")

        except Exception as e:
            logger.warning(f"[AIGIE] Error in _handle_tts_stopped: {e}")
        finally:
            self.current_tts_span_id = None
            self._tts_start_time = None

    async def _handle_tts_audio(self, data: "FramePushed") -> None:
        """Handle TTSAudioRawFrame - raw TTS audio data."""
        # Record TTFB on first audio frame
        if not self._tts_first_audio and self._current_turn_metrics:
            self._current_turn_metrics.record_tts_first_audio()
            self._tts_first_audio = True

        # Capture audio metadata if enabled
        if self.config.capture_audio_metadata and self._current_turn_metrics:
            frame = data.frame
            self._current_turn_metrics.audio_sample_rate = getattr(frame, 'sample_rate', None)
            self._current_turn_metrics.audio_channels = getattr(frame, 'num_channels', None)

    async def _handle_audio_frame(self, data: "FramePushed") -> None:
        """Handle generic AudioRawFrame."""
        # Similar to TTS audio handling for TTFB
        if not self._tts_first_audio and self._current_turn_metrics:
            self._current_turn_metrics.record_tts_first_audio()
            self._tts_first_audio = True

    # ========================================================================
    # Metrics Handlers
    # ========================================================================

    async def _handle_metrics(self, data: "FramePushed") -> None:
        """Handle MetricsFrame - general metrics from pipeline."""
        if not self.config.capture_token_usage:
            return

        frame = data.frame

        # Extract any token metrics
        if self._current_turn_metrics:
            input_tokens = getattr(frame, 'input_tokens', None)
            output_tokens = getattr(frame, 'output_tokens', None)

            if input_tokens is not None:
                self._current_turn_metrics.input_tokens = input_tokens
            if output_tokens is not None:
                self._current_turn_metrics.output_tokens = output_tokens

    async def _handle_ttfb_metrics(self, data: "FramePushed") -> None:
        """Handle TTFBMetricsFrame - time-to-first-byte metrics."""
        if not self.config.capture_ttfb or not self._current_turn_metrics:
            return

        frame = data.frame

        # Extract TTFB values if provided by the frame
        ttfb = getattr(frame, 'ttfb', None)
        if ttfb and isinstance(ttfb, (int, float)):
            # Determine which service this TTFB is for
            processor = getattr(frame, 'processor', None)
            if processor:
                processor_name = str(processor).lower()
                if 'stt' in processor_name or 'transcription' in processor_name:
                    self._current_turn_metrics.stt_ttfb_ms = ttfb
                elif 'llm' in processor_name:
                    self._current_turn_metrics.llm_ttfb_ms = ttfb
                elif 'tts' in processor_name:
                    self._current_turn_metrics.tts_ttfb_ms = ttfb

    async def _handle_processing_metrics(self, data: "FramePushed") -> None:
        """Handle ProcessingMetricsFrame - processing time metrics."""
        # Currently just log, can be extended for detailed metrics
        pass

    # ========================================================================
    # Error Handlers
    # ========================================================================

    async def _handle_error(self, data: "FramePushed") -> None:
        """Handle ErrorFrame - pipeline error."""
        aigie = self._get_aigie()
        if not aigie or not aigie._initialized or not self.trace_id:
            return

        try:
            frame = data.frame
            error_msg = getattr(frame, 'error', None) or getattr(frame, 'message', 'Unknown error')
            error_str = str(error_msg)

            self._has_errors = True
            if error_str not in self._error_messages:
                self._error_messages.append(error_str)

            # Detect and classify the error
            detected = self._error_detector.detect_from_text(
                error_str,
                source="pipeline",
                context={"turn_number": self._turn_count}
            )

            # Create error event span
            # Use "guardrail" type which is a valid SpanType (event is not in the enum)
            span_id = str(uuid.uuid4())
            timestamp = _utc_now()

            span_data = {
                "id": span_id,
                "trace_id": self.trace_id,
                "parent_id": self.current_turn_span_id or self.conversation_span_id,
                "name": "Error",
                "type": "guardrail",  # Valid SpanType - error is like a guardrail event
                "start_time": timestamp.isoformat(),
                "end_time": timestamp.isoformat(),
                "duration_ns": 0,
                "status": "error",
                "is_error": True,
                "error": error_str[:500],
                "metadata": {
                    "error_type": detected.error_type.value if detected else "unknown",
                    "severity": detected.severity.value if detected else "high",
                    "span_category": "error",  # Keep original type in metadata
                },
            }

            await aigie._buffer.add(EventType.SPAN_CREATE, span_data)

            logger.warning(f"[AIGIE] Pipeline error: {error_str[:100]}")

        except Exception as e:
            logger.warning(f"[AIGIE] Error in _handle_error: {e}")

    # ========================================================================
    # Helper Methods
    # ========================================================================

    def _reset_conversation_state(self) -> None:
        """Reset all state for a new conversation."""
        self.conversation_span_id = None
        self.current_turn_span_id = None
        self.current_stt_span_id = None
        self.current_llm_span_id = None
        self.current_tts_span_id = None

        self._stt_start_time = None
        self._llm_start_time = None
        self._tts_start_time = None
        self._turn_start_time = None
        self._user_stopped_speaking_time = None

        self._stt_first_result = False
        self._llm_first_token = False
        self._tts_first_audio = False

        self._stt_calls.clear()
        self._llm_calls.clear()
        self._tts_calls.clear()

        self._current_turn_metrics = None
        self._metrics_aggregator = MetricsAggregator()
        self._error_detector = VoiceErrorDetector()

        self._has_errors = False
        self._error_messages = []
        self._turn_count = 0
        self._in_turn = False
        self._current_transcription = ""
        self._current_bot_response = ""
