import asyncio
import contextlib
import datetime
import logging
import time
import uuid
from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypeGuard
from uuid import uuid4


import getstream.models
from aiortc import VideoStreamTrack
from getstream.video.rtc import Call

from getstream.video.rtc.participants import ParticipantsState
from getstream.video.rtc.pb.stream.video.sfu.models.models_pb2 import TrackType
from .agent_types import AgentOptions, default_agent_options, LLMTurn, TrackInfo

from ..edge import sfu_events
from ..edge.events import (
    AudioReceivedEvent,
    TrackAddedEvent,
    TrackRemovedEvent,
    CallEndedEvent,
)
from ..edge.types import Connection, Participant, PcmData, User, OutputAudioTrack
from ..events.manager import EventManager
from ..llm import events as llm_events
from ..llm.events import (
    LLMResponseChunkEvent,
    LLMResponseCompletedEvent,
    RealtimeUserSpeechTranscriptionEvent,
    RealtimeAgentSpeechTranscriptionEvent,
    RealtimeAudioOutputEvent,
)
from ..llm.llm import AudioLLM, LLM, VideoLLM
from ..llm.realtime import Realtime
from ..mcp import MCPBaseServer, MCPManager
from ..processors.base_processor import Processor, ProcessorType, filter_processors
from ..stt.events import STTTranscriptEvent, STTErrorEvent
from ..stt.stt import STT
from ..tts.tts import TTS
from ..tts.events import TTSAudioEvent
from ..turn_detection import TurnDetector, TurnStartedEvent, TurnEndedEvent
from ..utils.audio_queue import AudioQueue
from ..utils.logging import (
    CallContextToken,
    clear_call_context,
    set_call_context,
    configure_default_logging,
)
from ..utils.video_forwarder import VideoForwarder
from ..vad import VAD
from ..vad.events import VADAudioEvent
from . import events
from .conversation import Conversation
from ..profiling import Profiler
from opentelemetry.trace import set_span_in_context
from opentelemetry.trace.propagation import Span, Context
from opentelemetry import trace, context as otel_context
from opentelemetry.trace import Tracer
from opentelemetry.context import Token

if TYPE_CHECKING:
    from vision_agents.plugins.getstream.stream_edge_transport import StreamEdge

    from .agent_session import AgentSessionContextManager

logger = logging.getLogger(__name__)

tracer: Tracer = trace.get_tracer("agents")


class Agent:
    """
    Agent class makes it easy to build your own video AI.

    Example:

        # realtime mode
        agent = Agent(
            edge=getstream.Edge(),
            agent_user=agent_user,
            instructions="Read @voice-agent.md",
            llm=gemini.Realtime(),
            processors=[],  # processors can fetch extra data, check images/audio data or transform video
        )

    Commonly used methods

    * agent.join(call) // join a call
    * agent.llm.simple_response("greet the user")
    * await agent.finish() // (wait for the call session to finish)
    * agent.close() // cleanup

    Note: Don't reuse the agent object. Create a new agent object each time.

    Dev guidelines
    - Small methods so its easy to subclass/change behaviour
    """

    def __init__(
        self,
        # edge network for video & audio
        edge: "StreamEdge",
        # llm, optionally with sts/realtime capabilities
        llm: LLM | AudioLLM | VideoLLM,
        # the agent's user info
        agent_user: User,
        # instructions
        instructions: str = "Keep your replies short and dont use special characters.",
        # setup stt, tts, and turn detection if not using a realtime llm
        stt: Optional[STT] = None,
        tts: Optional[TTS] = None,
        turn_detection: Optional[TurnDetector] = None,
        vad: Optional[VAD] = None,
        # for video gather data at an interval
        # - roboflow/ yolo typically run continuously
        # - often combined with API calls to fetch stats etc
        # - state from each processor is passed to the LLM
        processors: Optional[List[Processor]] = None,
        # MCP servers for external tool and resource access
        mcp_servers: Optional[List[MCPBaseServer]] = None,
        options: Optional[AgentOptions] = None,
        tracer: Tracer = trace.get_tracer("agents"),
        # Configure the default logging for the sdk here. Pass None to leave the config intact.
        log_level: Optional[int] = logging.INFO,
        profiler: Optional[Profiler] = None,
    ):
        self._pending_turn: Optional[LLMTurn] = None
        self.participants: Optional[ParticipantsState] = None
        self.call = None
        self._active_processed_track_id: Optional[str] = None
        self._active_source_track_id: Optional[str] = None
        if log_level is not None:
            configure_default_logging(level=log_level)
        if options is None:
            options = default_agent_options()
        else:
            options = default_agent_options().update(options)
        self.options = options
        self._audio_queue: AudioQueue = AudioQueue(buffer_limit_ms=8000)

        self.instructions = instructions
        self.edge = edge
        self.agent_user = agent_user
        self._agent_user_initialized = False

        # only needed in case we spin threads
        self.tracer = tracer
        self._root_span: Optional[Span] = None
        self._root_ctx: Optional[Context] = None

        self.logger = _AgentLoggerAdapter(logger, {"agent_id": self.agent_user.id})

        self.events = EventManager()
        self.events.register_events_from_module(getstream.models, "call.")
        self.events.register_events_from_module(events)
        self.events.register_events_from_module(sfu_events)
        self.events.register_events_from_module(llm_events)

        self.llm = llm
        self.stt = stt
        self.tts = tts
        self.turn_detection = turn_detection
        self.vad = vad
        self.processors = processors or []
        self.mcp_servers = mcp_servers or []
        self._call_context_token: CallContextToken | None = None
        self._context_token: Token[Context] | None = None

        # Initialize MCP manager if servers are provided
        self.mcp_manager = (
            MCPManager(self.mcp_servers, self.llm, self.logger)
            if self.mcp_servers
            else None
        )

        # we sync the user talking and the agent responses to the conversation
        # because we want to support streaming responses and can have delta updates for both
        # user and agent
        self.conversation: Optional[Conversation] = None

        # Track pending transcripts for turn-based response triggering
        self._pending_user_transcripts: Dict[str, str] = {}

        # Merge plugin events BEFORE subscribing to any events
        for plugin in [stt, tts, turn_detection, vad, llm, edge, profiler]:
            if plugin and hasattr(plugin, "events"):
                self.logger.debug(f"Register events from plugin {plugin}")
                self.events.merge(plugin.events)

        self.llm._attach_agent(self)

        # Attach processors that need agent reference
        for processor in self.processors:
            if hasattr(processor, "_attach_agent"):
                processor._attach_agent(self)

        self.events.subscribe(self._on_vad_audio)
        self.events.subscribe(self._on_agent_say)
        # Initialize state variables
        self._is_running: bool = False
        self._current_frame = None
        self._interval_task = None
        self._callback_executed = False
        self._track_tasks: Dict[str, asyncio.Task] = {}
        # Track metadata: track_id -> TrackInfo
        self._active_video_tracks: Dict[str, TrackInfo] = {}
        self._video_forwarders: List[VideoForwarder] = []
        self._current_video_track_id: Optional[str] = None
        self._connection: Optional[Connection] = None
        self._audio_track: Optional[OutputAudioTrack] = None
        self._video_track: Optional[VideoStreamTrack] = None
        self._realtime_connection = None
        self._pc_track_handler_attached: bool = False
        self._audio_consumer_task: Optional[asyncio.Task] = None

        # validation time
        self._validate_configuration()
        self._prepare_rtc()

        # start audio consumption loop
        self.setup_event_handling()

        self.events.send(events.AgentInitEvent())

    async def _finish_llm_turn(self):
        if self._pending_turn is None or self._pending_turn.response is None:
            raise ValueError(
                "Finish LLM turn should only be called after self._pending_turn is set"
            )
        turn = self._pending_turn
        self._pending_turn = None
        event = turn.response
        if self.tts and event and event.text and event.text.strip():
            await self.tts.send(event.text)

    def setup_event_handling(self):
        """
        Agent event handling:

        - STT: AudioReceivedEvent -> STTTranscriptEvent -> TurnCompleted -> LLMResponseCompletedEvent -> TTSAudioEvent
        - Eager: AudioReceivedEvent -> STTTranscriptEvent -> EagerTurnCompleted -> LLMResponseCompletedEvent
            - > if TurnCompleted -> TTSAudioEvent
        - Realtime: Transcriptions

        Other events
        - Tracks for video added/removed
        - Error events

        """
        # listen to turn completed, started etc
        self.events.subscribe(self._on_turn_event)

        if self.stt:

            @self.stt.events.subscribe
            async def on_turn_ended(event: TurnEndedEvent):
                logger.info("Received TurnEndedEvent %s", event)

        @self.llm.events.subscribe
        async def on_llm_response_send_to_tts(event: LLMResponseCompletedEvent):
            # turns started outside of the agent (instructions from code)
            if self._pending_turn is None:
                if self.tts and event.text and event.text.strip():
                    await self.tts.send(event.text)
            else:
                self._pending_turn.response = event
                if self._pending_turn.turn_finished:
                    await self._finish_llm_turn()
                else:
                    # we are in eager turn completion mode. wait for confirmation
                    self._pending_turn.response = event

        # write tts pcm to output track (this is the AI talking to us)
        @self.events.subscribe
        async def _on_tts_audio_write_to_output(event: TTSAudioEvent):
            if self._audio_track is not None:
                await self._audio_track.write(event.data)

        # listen to video tracks added/removed
        @self.edge.events.subscribe
        async def on_video_track_added(event: TrackAddedEvent | TrackRemovedEvent):
            if event.track_id is None or event.track_type is None or event.user is None:
                return
            if isinstance(event, TrackRemovedEvent):
                asyncio.create_task(
                    self._on_track_removed(event.track_id, event.track_type, event.user)
                )
            else:
                asyncio.create_task(
                    self._on_track_added(event.track_id, event.track_type, event.user)
                )

        # audio event for the user talking to the AI
        @self.edge.events.subscribe
        async def on_audio_received(event: AudioReceivedEvent):
            if event.participant is not None:
                await self._reply_to_audio(event.pcm_data, event.participant)

        @self.events.subscribe
        async def on_stt_transcript_event_create_response(event: STTTranscriptEvent):
            if _is_audio_llm(self.llm):
                # There is no need to send the response to the LLM if it handles audio itself.
                return

            user_id = event.user_id()
            if user_id is None:
                raise ValueError("user id is none, this indicates a bug in the code")

            # Determine how to handle LLM triggering based on turn detection
            # With turn detection: accumulate transcripts and wait for TurnEndedEvent
            # Store/append the transcript for this user
            if user_id not in self._pending_user_transcripts:
                self._pending_user_transcripts[user_id] = event.text
            else:
                # Append to existing transcript (user might be speaking in chunks)
                self._pending_user_transcripts[user_id] += " " + event.text

            self.logger.debug(
                f"📝 Accumulated transcript for {user_id} (waiting for turn end): "
                f"{self._pending_user_transcripts[user_id][:100]}..."
            )

            # if turn detection is disabled, treat the transcript event as an end of turn
            if not self.turn_detection_enabled:
                self.events.send(
                    TurnEndedEvent(
                        participant=event.participant,
                    )
                )

        # TODO: chat event handling needs work

        # Error handling
        @self.events.subscribe
        async def on_error(event: STTErrorEvent):
            self.logger.error("stt error event %s", event)

        @self.events.subscribe
        async def on_stt_transcript_event_sync_conversation(event: STTTranscriptEvent):
            self.logger.info(f"🎤 [Transcript]: {event.text}")

            if self.conversation is None:
                return

            user_id = event.user_id()
            if user_id is None:
                raise ValueError("missing user_id")

            with self.span("agent.on_stt_transcript_event_sync_conversation"):
                await self.conversation.upsert_message(
                    message_id=str(uuid.uuid4()),
                    role="user",
                    user_id=user_id,
                    content=event.text or "",
                    completed=True,
                    replace=True,  # Replace any partial transcripts
                    original=event,
                )

        @self.events.subscribe
        async def on_realtime_user_speech_transcription(
            event: RealtimeUserSpeechTranscriptionEvent,
        ):
            self.logger.info(f"🎤 [User transcript]: {event.text}")

            if self.conversation is None or not event.text:
                return

            with self.span("agent.on_realtime_user_speech_transcription"):
                await self.conversation.upsert_message(
                    message_id=str(uuid.uuid4()),
                    role="user",
                    user_id=event.user_id() or "",
                    content=event.text,
                    completed=True,
                    replace=True,
                    original=event,
                )

        @self.events.subscribe
        async def on_realtime_agent_speech_transcription(
            event: RealtimeAgentSpeechTranscriptionEvent,
        ):
            self.logger.info(f"🎤 [Agent transcript]: {event.text}")

            if self.conversation is None or not event.text:
                return

            with self.span("agent.on_realtime_agent_speech_transcription"):
                await self.conversation.upsert_message(
                    message_id=str(uuid.uuid4()),
                    role="assistant",
                    user_id=self.agent_user.id or "",
                    content=event.text,
                    completed=True,
                    replace=True,
                    original=event,
                )

        @self.llm.events.subscribe
        async def on_llm_response_sync_conversation(event: LLMResponseCompletedEvent):
            self.logger.info(f"🤖 [LLM response]: {event.text} {event.item_id}")

            if self.conversation is None:
                return

            with self.span("agent.on_llm_response_sync_conversation"):
                # Unified API: handles both streaming and non-streaming
                await self.conversation.upsert_message(
                    message_id=event.item_id,
                    role="assistant",
                    user_id=self.agent_user.id or "agent",
                    content=event.text or "",
                    completed=True,
                    replace=True,  # Replace any partial content from deltas
                )

        @self.llm.events.subscribe
        async def _handle_output_text_delta(event: LLMResponseChunkEvent):
            """Handle partial LLM response text deltas."""

            self.logger.info(
                f"🤖 [LLM delta response]: {event.delta} {event.item_id} {event.content_index}"
            )

            if self.conversation is None:
                return

            with self.span("agent._handle_output_text_delta"):
                await self.conversation.upsert_message(
                    message_id=event.item_id,
                    role="assistant",
                    user_id=self.agent_user.id or "agent",
                    content=event.delta or "",
                    content_index=event.content_index,
                    completed=False,  # Still streaming
                )

    async def simple_response(
        self, text: str, participant: Optional[Participant] = None
    ) -> None:
        """
        Overwrite simple_response if you want to change how the Agent class calls the LLM
        """
        self.logger.info('🤖 Asking LLM to reply to "%s"', text)
        with self.tracer.start_as_current_span("simple_response") as span:
            response = await self.llm.simple_response(
                text=text, processors=self.processors, participant=participant
            )
            span.set_attribute("text", text)
            span.set_attribute("response.text", response.text)
            span.set_attribute("response.original", response.original)

    async def simple_audio_response(
        self, pcm: PcmData, participant: Optional[Participant] = None
    ) -> None:
        """
        Makes it easy to subclass how the agent calls the LLM for processing audio
        """
        if _is_audio_llm(self.llm):
            await self.llm.simple_audio_response(pcm, participant)

    def subscribe(self, function):
        """Subscribe a callback to the agent-wide event bus.

        The event bus is a merged stream of events from the edge, LLM, STT, TTS,
        VAD, and other registered plugins.

        Args:
            function: Async or sync callable that accepts a single event object.

        Returns:
            A disposable subscription handle (depends on the underlying emitter).
        """
        return self.events.subscribe(function)

    async def join(
        self, call: Call, wait_for_participant=True
    ) -> "AgentSessionContextManager":
        # TODO: validation. join can only be called once
        self.logger.info("joining call")
        # run start on all subclasses
        await self._apply("start")
        self._start_tracing()

        if self._root_span:
            self._root_span.set_attribute("call_id", call.id)
            if self.agent_user.id:
                self._root_span.set_attribute("agent_id", self.agent_user.id)

        if self._is_running:
            raise RuntimeError("Agent is already running")

        await self.create_user()

        self.call = call
        self.conversation = None

        # Ensure all subsequent logs include the call context.
        self._set_call_logging_context(call.id)

        # Setup chat and connect it to transcript events (we'll wait at the end)
        create_conversation_coro = self.edge.create_conversation(
            call, self.agent_user, self.instructions
        )

        try:
            # Connect to MCP servers if manager is available
            if self.mcp_manager:
                with self.span("mcp_manager.connect_all"):
                    await self.mcp_manager.connect_all()

            # Ensure Realtime providers are ready before proceeding (they manage their own connection)
            self.logger.info(f"🤖 Agent joining call: {call.id}")
            if _is_realtime_llm(self.llm):
                await self.llm.connect()

            with self.span("edge.join"):
                connection = await self.edge.join(self, call)
                self.participants = connection.participants

        except Exception:
            self.clear_call_logging_context()
            raise

        self._connection = connection
        self._is_running = True
        self._audio_consumer_task = asyncio.create_task(self._reply_to_audio_consumer())

        self.logger.info(f"🤖 Agent joined call: {call.id}")

        # Set up audio and video tracks together to avoid SDP issues
        audio_track = self._audio_track if self.publish_audio else None
        video_track = self._video_track if self.publish_video else None

        if audio_track or video_track:
            with self.span("edge.publish_tracks"):
                await self.edge.publish_tracks(audio_track, video_track)

        connection._connection._coordinator_ws_client.on_wildcard(
            "*",
            lambda event_name, event: self.events.send(event),
        )

        connection._connection._ws_client.on_wildcard(
            "*",
            lambda event_name, event: self.events.send(event),
        )

        from .agent_session import AgentSessionContextManager

        # wait for conversation creation coro at the very end of the join flow
        self.conversation = await create_conversation_coro

        if wait_for_participant:
            self.logger.info("Agent is ready, waiting for participant to join")
            await self.wait_for_participant()

        return AgentSessionContextManager(self, self._connection)

    async def wait_for_participant(self):
        """wait for a participant other than the AI agent to join"""

        if self.participants is None:
            return

        participant_joined = asyncio.Event()

        def on_participants(participants):
            for p in participants:
                if p.user_id != self.agent_user.id:
                    participant_joined.set()

        subscription = self.participants.map(on_participants)

        try:
            await participant_joined.wait()
        finally:
            subscription.unsubscribe()

    async def finish(self):
        """Wait for the call to end gracefully.
        Subscribes to the edge transport's `call_ended` event and awaits it. If
        no connection is active, returns immediately.
        """
        if not self._connection:
            self.logger.info(
                "🔚 Agent connection is already closed, finishing immediately"
            )
            return

        running_event = asyncio.Event()
        with self.span("agent.finish"):
            # If connection is None or already closed, return immediately
            if not self._connection:
                logging.info(
                    "🔚 Agent connection already closed, finishing immediately"
                )
                return

            @self.edge.events.subscribe
            async def on_ended(event: CallEndedEvent):
                running_event.set()
                self._is_running = False
        # TODO: add members count check (particiapnts left + count = 1 timeout 2 minutes)

        try:
            await running_event.wait()
        except asyncio.CancelledError:
            running_event.clear()

        self.events.send(events.AgentFinishEvent())

        await asyncio.shield(self.close())

    @contextlib.contextmanager
    def span(self, name):
        with tracer.start_as_current_span(name, context=self._root_ctx) as span:
            yield span

    def _start_tracing(self):
        self._root_span = tracer.start_span("join")
        self._root_span.__enter__()
        self._root_ctx = set_span_in_context(self._root_span)
        # Activate the root context globally so all subsequent spans are nested under it
        self._context_token = otel_context.attach(self._root_ctx)

    async def _apply(self, function_name: str, *args, **kwargs):
        subclasses = [self.llm, self.stt, self.tts, self.turn_detection, self.edge]
        subclasses.extend(self.processors)
        for subclass in subclasses:
            if (
                subclass is not None
                and getattr(subclass, function_name, None) is not None
            ):
                func = getattr(subclass, function_name)
                if func is not None:
                    try:
                        await func(*args, **kwargs)
                    except Exception as e:
                        self.logger.exception(
                            f"Error calling {function_name} on {subclass.__class__.__name__}: {e}"
                        )

    def _end_tracing(self):
        if self._root_span is not None:
            self._root_span.__exit__(None, None, None)
            self._root_span = None
            self._root_ctx = None
        # Detach the context token if it was set
        if hasattr(self, "_context_token") and self._context_token is not None:
            otel_context.detach(self._context_token)
            self._context_token = None

    def __aexit__(self, exc_type, exc_val, exc_tb):
        self._end_tracing()

    async def close(self):
        """Clean up all connections and resources.

        Closes MCP connections, realtime output, active media tracks, processor
        tasks, the call connection, STT/TTS services, and stops turn detection.
        Safe to call multiple times.

        This is an async method because several components expose async shutdown
        hooks (e.g., WebRTC connections, plugin services).
        """
        self._end_tracing()
        self._is_running = False
        self.clear_call_logging_context()

        # Stop audio consumer task
        if self._audio_consumer_task:
            self._audio_consumer_task.cancel()
            try:
                await self._audio_consumer_task
            except asyncio.CancelledError:
                pass
            self._audio_consumer_task = None

        # run stop on all subclasses
        await self._apply("stop")
        # run close on all subclasses
        await self._apply("close")

        # Disconnect from MCP servers
        if self.mcp_manager:
            await self.mcp_manager.disconnect_all()

        # Stop all video forwarders
        if hasattr(self, "_video_forwarders"):
            for forwarder in self._video_forwarders:
                try:
                    await forwarder.stop()
                except Exception as e:
                    self.logger.error(f"Error stopping video forwarder: {e}")
            self._video_forwarders.clear()

        # Close Realtime connection
        if self._realtime_connection:
            await self._realtime_connection.__aexit__(None, None, None)
        self._realtime_connection = None

        # shutdown task processing
        for _, track in self._track_tasks.items():
            track.cancel()

        # Close RTC connection
        if self._connection:
            await self._connection.close()
        self._connection = None

        # Stop audio track
        if self._audio_track:
            self._audio_track.stop()
        self._audio_track = None

        # Stop video track
        if self._video_track:
            self._video_track.stop()
        self._video_track = None

        # Cancel interval task
        if self._interval_task:
            self._interval_task.cancel()
        self._interval_task = None

    # ------------------------------------------------------------------
    # Logging context helpers
    # ------------------------------------------------------------------
    def _set_call_logging_context(self, call_id: str) -> None:
        """Apply the call id to the logging context for the agent lifecycle."""

        if self._call_context_token is not None:
            self.clear_call_logging_context()
        self._call_context_token = set_call_context(call_id)

    def clear_call_logging_context(self) -> None:
        """Remove the call id from the logging context if present."""

        if self._call_context_token is not None:
            clear_call_context(self._call_context_token)
            self._call_context_token = None

    async def create_user(self) -> None:
        """Create the agent user in the edge provider, if required."""

        if self._agent_user_initialized:
            return None

        with self.span("edge.create_user"):
            if not self.agent_user.id:
                self.agent_user.id = f"agent-{uuid4()}"
            await self.edge.create_user(self.agent_user)
            self._agent_user_initialized = True

        return None

    async def create_call(self, call_type: str, call_id: str) -> Call:
        """Shortcut for creating a call/room etc."""
        call = self.edge.client.video.call(call_type, call_id)
        await call.get_or_create(data={"created_by_id": self.agent_user.id})

        return call

    async def _on_vad_audio(self, event: VADAudioEvent):
        self.logger.debug(f"Vad audio event {self._truncate_for_logging(event)}")

    def _on_rtc_reconnect(self):
        # update the code to listen?
        # republish the audio track and video track?
        # TODO: implement me
        pass

    async def _on_agent_say(self, event: events.AgentSayEvent):
        """Handle agent say events by calling TTS if available."""
        try:
            # Emit say started event
            synthesis_id = str(uuid4())
            self.events.send(
                events.AgentSayStartedEvent(
                    plugin_name="agent",
                    text=event.text,
                    user_id=event.user_id,
                    synthesis_id=synthesis_id,
                )
            )

            start_time = time.time()

            if self.tts is not None:
                # Call TTS with user metadata
                user_metadata = {"user_id": event.user_id}
                if event.metadata:
                    user_metadata.update(event.metadata)

                await self.tts.send(event.text, user_metadata)

                # Calculate duration
                duration_ms = (time.time() - start_time) * 1000

                # Emit say completed event
                self.events.send(
                    events.AgentSayCompletedEvent(
                        plugin_name="agent",
                        text=event.text,
                        user_id=event.user_id,
                        synthesis_id=synthesis_id,
                        duration_ms=duration_ms,
                    )
                )

                self.logger.info(f"🤖 Agent said: {event.text}")
            else:
                self.logger.warning("No TTS available, cannot synthesize speech")

        except Exception as e:
            # Emit say error event
            self.events.send(
                events.AgentSayErrorEvent(
                    plugin_name="agent",
                    text=event.text,
                    user_id=event.user_id,
                    error=e,
                )
            )
            self.logger.error(f"Error in agent say: {e}")

    async def say(
        self,
        text: str,
        user_id: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ):
        """
        Make the agent say something using TTS.

        This is a convenience method that sends an AgentSayEvent to trigger TTS synthesis.

        Args:
            text: The text for the agent to say
            user_id: Optional user ID for the speech
            metadata: Optional metadata to include with the speech
        """
        self.events.send(
            events.AgentSayEvent(
                plugin_name="agent",
                text=text,
                user_id=user_id or self.agent_user.id,
                metadata=metadata,
            )
        )

        if self.conversation is not None:
            await self.conversation.upsert_message(
                role="assistant",
                user_id=user_id or self.agent_user.id or "agent",
                content=text,
                completed=True,
            )

    async def _reply_to_audio(
        self, pcm_data: PcmData, participant: Participant
    ) -> None:
        """Put audio data into the queue for processing by the consumer."""
        # TODO: bit of a hack..
        pcm_data.participant = participant
        await self._audio_queue.put(pcm_data)

    async def _reply_to_audio_consumer(self) -> None:
        """Consumer that continuously processes audio from the queue."""
        self.logger.info("AUDIO: Starting audio consumer")
        try:
            while self._is_running:
                try:
                    # Get audio data from queue with timeout to allow checking _is_running
                    pcm = await asyncio.wait_for(
                        self._audio_queue.get_duration(duration_ms=20), timeout=1.0
                    )

                    participant = pcm.participant

                    if self.turn_detection is not None and participant is not None:
                        await self.turn_detection.process_audio(
                            pcm, participant, conversation=self.conversation
                        )

                    if (
                        participant
                        and getattr(participant, "user_id", None) != self.agent_user.id
                    ):
                        # first forward to processors
                        # Extract audio bytes for processors using the proper PCM data structure
                        # PCM data has: format, sample_rate, samples, pts, dts, time_base
                        audio_bytes = pcm.samples.tobytes()

                        for processor in self.audio_processors:
                            if processor is None:
                                continue
                            await processor.process_audio(
                                audio_bytes, participant.user_id
                            )

                        # when in Realtime mode call the Realtime directly (non-blocking)
                        if _is_audio_llm(self.llm):
                            await self.simple_audio_response(pcm, participant)

                        # Process audio through STT
                        elif self.stt:
                            await self.stt.process_audio(pcm, participant)

                except (asyncio.TimeoutError, asyncio.QueueEmpty):
                    await asyncio.sleep(0.02)

                    # No audio data available, continue loop to check _is_running
                    continue

        except asyncio.CancelledError:
            self.logger.info("🎵 Audio consumer task cancelled")
            raise
        except Exception as e:
            self.logger.error(f"❌ Error in audio consumer: {e}", exc_info=True)

    async def _track_to_video_processors(self, track: TrackInfo):
        """
        Send the track to the video processors
        """
        # video processors - pass the raw forwarder (they process incoming frames)
        for processor in self.video_processors:
            try:
                user_id = track.participant.user_id if track.participant else None
                await processor.process_video(
                    track.track, user_id, shared_forwarder=track.forwarder
                )
            except Exception as e:
                self.logger.error(
                    f"Error in video processor {type(processor).__name__}: {e}"
                )

    async def _image_to_video_processors(self, track_id: str, track_type: int):
        """
        Send the current image to the image processors
        """
        track_info = self._active_video_tracks.get(track_id)
        if not track_info:
            return

        for processor in self.image_processors:
            try:
                pass
                # TODO: run this better
                # await processor.process_image(
                #    img, track_info.participant.user_id, track_id=track_id, track_type=track_type
                # )
            except Exception as e:
                self.logger.error(
                    f"Error in image processor {type(processor).__name__}: {e}"
                )

    async def _on_track_removed(
        self, track_id: str, track_type: int, participant: Participant
    ):
        self._active_video_tracks.pop(track_id)
        await self._on_track_change(track_id)

    async def _on_track_change(self, track_id: str):
        # shared logic between track remove and added
        # Select a track. Prioritize screenshare over regular
        # This is the track without processing
        non_processed_tracks = [
            t for t in self._active_video_tracks.values() if not t.processor
        ]
        source_track = sorted(
            non_processed_tracks, key=lambda t: t.priority, reverse=True
        )[0]
        # assign the tracks that we last used so we can notify of changes...
        self._active_source_track_id = source_track.id

        await self._track_to_video_processors(source_track)

        processed_track = sorted(
            [t for t in self._active_video_tracks.values()],
            key=lambda t: t.priority,
            reverse=True,
        )[0]
        self._active_processed_track_id = processed_track.id

        # See if we have a processed track. If so forward that to LLM
        # TODO: this should run in a loop and handle multiple forwarders
        # self._image_to_video_processors()

        # If Realtime provider supports video, switch to this new track
        if _is_video_llm(self.llm):
            logger.info("watch video called with track %s", processed_track)
            await self.llm.watch_video_track(
                processed_track.track, shared_forwarder=processed_track.forwarder
            )

    async def _on_track_added(
        self, track_id: str, track_type: int, participant: Participant
    ):
        # We only process video tracks (camera video or screenshare)
        if track_type not in (
            TrackType.TRACK_TYPE_VIDEO,
            TrackType.TRACK_TYPE_SCREEN_SHARE,
        ):
            return

        # Subscribe to the video track, we watch all tracks by default
        track = self.edge.add_track_subscriber(track_id)
        if not track:
            self.logger.error(f"Failed to subscribe to {track_id}")
            return

        # Store track metadata
        forwarder = VideoForwarder(
            track,  # type: ignore[arg-type]
            max_buffer=30,
            fps=30,  # Max FPS for the producer (individual consumers can throttle down)
            name=f"video_forwarder_{track_id}_{track_type}",
        )
        self._active_video_tracks[track_id] = TrackInfo(
            id=track_id,
            type=track_type,
            processor="",
            track=track,
            participant=participant,
            priority=1 if track_type == TrackType.TRACK_TYPE_SCREEN_SHARE else 0,
            forwarder=forwarder,
        )

        await self._on_track_change(track_id)

    async def _on_turn_event(self, event: TurnStartedEvent | TurnEndedEvent) -> None:
        """Handle turn detection events."""
        logger.info("_on_turn_event")
        # Skip the turn event handling if the model doesn't require TTS or SST audio itself.
        if _is_audio_llm(self.llm):
            return

        if isinstance(event, TurnStartedEvent):
            # Interrupt TTS when user starts speaking (barge-in)
            if event.participant and event.participant.user_id != self.agent_user.id:
                if self.tts:
                    self.logger.info(
                        f"👉 Turn started - interrupting TTS for participant {event.participant.user_id}"
                    )
                    await self.tts.stop_audio()
                else:
                    participant_id = (
                        event.participant.user_id if event.participant else "unknown"
                    )
                    self.logger.info(
                        f"👉 Turn started - participant speaking {participant_id} : {event.confidence}"
                    )
                if self._audio_track is not None:
                    await self._audio_track.flush()
            else:
                # Agent itself started speaking - this is normal
                participant_id = (
                    event.participant.user_id if event.participant else "unknown"
                )
                self.logger.debug(f"👉 Turn started - agent speaking {participant_id}")
        elif isinstance(event, TurnEndedEvent):
            participant_id = (
                event.participant.user_id if event.participant else "unknown"
            )
            self.logger.info(
                f"👉 Turn ended - participant {participant_id} finished (confidence: {event.confidence})"
            )
            if not event.participant or event.participant.user_id == self.agent_user.id:
                # Exit early if the event is triggered by the model response.
                return

            # When turn detection is enabled, trigger LLM response when user's turn ends.
            # This is the signal that the user has finished speaking and expects a response
            transcript = self._pending_user_transcripts.get(
                event.participant.user_id, ""
            )
            if transcript.strip():
                self.logger.info(
                    f"🤖 Triggering LLM response after turn ended for {event.participant.user_id}"
                )

                # Create participant object if we have metadata
                participant = event.participant

                # Clear the pending transcript for this speaker
                self._pending_user_transcripts[participant.user_id] = ""
                # cancel the old task if the text changed in the meantime

                if (
                    self._pending_turn is not None
                    and self._pending_turn.input != transcript
                ):
                    logger.debug(
                        "Eager turn and completed turn didn't match. Cancelling in flight response. %s vs %s ",
                        self._pending_turn.input,
                        transcript,
                    )
                    if self._pending_turn.task:
                        self._pending_turn.task.cancel()

                # create a new LLM turn
                if self._pending_turn is None or self._pending_turn.input != transcript:
                    # Without turn detection: trigger LLM immediately on transcript completion
                    # This is the traditional STT -> LLM flow
                    llm_turn = LLMTurn(
                        input=transcript,
                        participant=event.participant,
                        started_at=datetime.datetime.now(),
                        turn_finished=not event.eager_end_of_turn,
                    )
                    self._pending_turn = llm_turn
                    task = asyncio.create_task(
                        self.simple_response(transcript, event.participant)
                    )
                    llm_turn.task = task
                elif self._pending_turn.input == transcript:
                    # same text as pending turn
                    is_finished = not event.eager_end_of_turn
                    now = datetime.datetime.now()
                    elapsed = now - self._pending_turn.started_at
                    logger.debug(
                        "Marking eager turn as completed. Eager turn detection saved %.2f",
                        elapsed.total_seconds() * 1000,
                    )

                    if is_finished:
                        self._pending_turn.turn_finished = True
                        if self._pending_turn.response is not None:
                            await self._finish_llm_turn()

    @property
    def turn_detection_enabled(self):
        # return true if either turn detection or stt provide turn detection capabilities
        return self.turn_detection is not None or (
            self.stt is not None and self.stt.turn_detection
        )

    @property
    def publish_audio(self) -> bool:
        """Whether the agent should publish an outbound audio track.

        Returns:
            True if TTS is configured, when in Realtime mode, or if there are audio publishers.
        """
        if self.tts is not None or _is_audio_llm(self.llm):
            return True
        # Also publish audio if there are audio publishers (e.g., HeyGen avatar)
        if self.audio_publishers:
            return True
        return False

    @property
    def publish_video(self) -> bool:
        """Whether the agent should publish an outbound video track."""
        return len(self.video_publishers) > 0

    def _needs_audio_or_video_input(self) -> bool:
        """Check if agent needs to listen to incoming audio or video.

        This determines whether the agent should register listeners for incoming
        media tracks from other participants. This is independent of whether the
        agent publishes its own tracks.

        Returns:
            True if any component needs audio/video input from other participants.

        Examples:
            - Agent with STT but no TTS: needs_audio=True (listen-only agent)
            - Agent with audio processors: needs_audio=True (analysis agent)
            - Agent with video processors: needs_video=True (frame analysis)
            - Agent with only LLM and TTS: needs_audio=False (announcement bot)
        """
        # Audio input needed for:
        # - STT (for transcription)
        # - Audio processors (for audio analysis)
        # Note: VAD and turn detection are helpers for STT/TTS, not standalone consumers
        needs_audio = self.stt is not None or len(self.audio_processors) > 0

        # Video input needed for:
        # - Video processors (for frame analysis)
        # - Realtime mode with video (multimodal LLMs)
        needs_video = len(self.video_processors) > 0 or _is_video_llm(self.llm)

        return needs_audio or needs_video

    @property
    def audio_processors(self) -> List[Any]:
        """Get processors that can process audio.

        Returns:
            List of processors that implement `process_audio(audio_bytes, user_id)`.
        """
        return filter_processors(self.processors, ProcessorType.AUDIO)

    @property
    def video_processors(self) -> List[Any]:
        """Get processors that can process video.

        Returns:
            List of processors that implement `process_video(track, user_id)`.
        """
        return filter_processors(self.processors, ProcessorType.VIDEO)

    @property
    def video_publishers(self) -> List[Any]:
        """Get processors capable of publishing a video track.

        Returns:
            List of processors that implement `create_video_track()`.
        """
        return filter_processors(self.processors, ProcessorType.VIDEO_PUBLISHER)

    @property
    def audio_publishers(self) -> List[Any]:
        """Get processors capable of publishing an audio track.

        Returns:
            List of processors that implement `create_audio_track()`.
        """
        return filter_processors(self.processors, ProcessorType.AUDIO_PUBLISHER)

    @property
    def image_processors(self) -> List[Any]:
        """Get processors that can process images.

        Returns:
            List of processors that implement `process_image()`.
        """
        return filter_processors(self.processors, ProcessorType.IMAGE)

    def _validate_configuration(self):
        """Validate the agent configuration."""
        if _is_audio_llm(self.llm):
            # Realtime mode - should not have separate STT/TTS
            if self.stt or self.tts:
                self.logger.warning(
                    "Realtime mode detected: STT and TTS services will be ignored. "
                    "The Realtime model handles both speech-to-text and text-to-speech internally."
                )
                # Realtime mode - should not have separate STT/TTS
            if self.stt or self.turn_detection:
                self.logger.warning(
                    "Realtime mode detected: STT, TTS and Turn Detection services will be ignored. "
                    "The Realtime model handles both speech-to-text, text-to-speech and turn detection internally."
                )
        else:
            # Traditional mode - check if we have audio processing or just video processing
            has_audio_processing = self.stt or self.tts or self.turn_detection
            has_video_processing = any(
                hasattr(p, "process_video") or hasattr(p, "process_image")
                for p in self.processors
            )

            if has_audio_processing and not self.llm:
                raise ValueError(
                    "LLM is required when using audio processing (STT/TTS/Turn Detection)"
                )

            # Allow video-only mode without LLM
            if not has_audio_processing and not has_video_processing:
                raise ValueError(
                    "At least one processing capability (audio or video) is required"
                )

    def _prepare_rtc(self):
        # Variables are now initialized in __init__

        if self.publish_audio:
            framerate = 48000
            stereo = True
            self._audio_track = self.edge.create_audio_track(
                framerate=framerate, stereo=stereo
            )

            @self.events.subscribe
            async def forward_audio(event: RealtimeAudioOutputEvent):
                if self._audio_track is not None:
                    await self._audio_track.write(event.data)

        # Set up video track if video publishers are available
        if self.publish_video:
            # Get the first video publisher to create the track
            video_publisher = self.video_publishers[0]
            # TODO: some lLms like moondream publish video
            self._video_track = video_publisher.publish_video_track()
            forwarder = VideoForwarder(
                self._video_track,  # type: ignore[arg-type]
                max_buffer=30,
                fps=30,  # Max FPS for the producer (individual consumers can throttle down)
                name=f"video_forwarder_{video_publisher.name}",
            )
            self._active_video_tracks[self._video_track.id] = TrackInfo(
                id=self._video_track.id,
                type=TrackType.TRACK_TYPE_VIDEO,
                processor=video_publisher.name,
                track=self._video_track,
                participant=None,
                priority=2,
                forwarder=forwarder,
            )

            self.logger.info("🎥 Video track initialized from video publisher")

    def _truncate_for_logging(self, obj, max_length=200):
        """Truncate object string representation for logging to prevent spam."""
        obj_str = str(obj)
        if len(obj_str) > max_length:
            obj_str = obj_str[:max_length] + "... (truncated)"
        return obj_str


def _is_audio_llm(llm: LLM | VideoLLM | AudioLLM) -> TypeGuard[AudioLLM]:
    return isinstance(llm, AudioLLM)


def _is_video_llm(llm: LLM | VideoLLM | AudioLLM) -> TypeGuard[VideoLLM]:
    return isinstance(llm, VideoLLM)


def _is_realtime_llm(llm: LLM | AudioLLM | VideoLLM | Realtime) -> TypeGuard[Realtime]:
    return isinstance(llm, Realtime)


def _log_task_exception(task: asyncio.Task):
    try:
        task.result()
    except Exception:
        logger.exception("Error in background task")


class _AgentLoggerAdapter(logging.LoggerAdapter):
    """
    A logger adapter to include the agent_id to the logs
    """

    def process(self, msg: str, kwargs):
        if self.extra:
            return "[Agent: %s] | %s" % (self.extra["agent_id"], msg), kwargs
        return super(_AgentLoggerAdapter, self).process(msg, kwargs)
