"""Base orchestrator for multi-agent coordination."""

from __future__ import annotations

import asyncio
import json
import os
import re
import time
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator
from typing import Any, Dict, List, Optional, Sequence, Union, cast

from agentbyte.agents.base import BaseAgent
from agentbyte.agents.types import AgentResponse, Usage
from agentbyte.cancellation_token import CancellationToken
from agentbyte.context import AgentContext
from agentbyte.messages import Message, UserMessage
from agentbyte.termination import BaseTermination
from agentbyte.types import (
    AgentExecutionCompleteEvent,
    AgentExecutionStartEvent,
    AgentSelectionEvent,
    OrchestrationCompleteEvent,
    OrchestrationEvent,
    OrchestrationResponse,
    OrchestrationStartEvent,
    StopMessage,
)

from .policies import AgentHistoryPolicy, FinalResultPolicy, HistoryContextPolicy


class BaseOrchestrator(ABC):
    """
    Abstract base class for all orchestration patterns.

    Defines the core orchestration interface and universal orchestration loop
    as specified in Chapter 7.1.

    Subclasses must implement three pattern-specific methods:
        - select_next_agent(): Choose which agent executes next
        - prepare_context_for_agent(): Format context for agent
        - update_shared_state(): Update state after agent execution

    Example:
        class MyOrchestrator(BaseOrchestrator):
            async def select_next_agent(self):
                # Pattern-specific logic
                return self.agents[0]

            async def prepare_context_for_agent(self, agent):
                # Pattern-specific logic
                return "Context string"

            async def update_shared_state(self, result):
                # Pattern-specific logic
                self.shared_messages.extend(result.messages)
    """

    def __init__(
        self,
        agents: Sequence[BaseAgent],
        termination: BaseTermination,
        max_iterations: int = 50,
        name: Optional[str] = None,
        description: Optional[str] = None,
        history_policy: HistoryContextPolicy | None = None,
        final_result_policy: FinalResultPolicy | None = None,
    ):
        """
        Initialize the orchestrator.

        Args:
            agents: List of agents available for orchestration
            termination: Termination condition
            max_iterations: Safety fallback for iterations (default: 50)
            name: Optional name for the orchestrator
            description: Optional description of orchestrator's purpose

        Raises:
            ValueError: If no agents provided or agent names not unique
        """
        if not agents:
            raise ValueError("At least one agent is required")

        self.agents = list(agents)
        self.termination = termination
        self.max_iterations = max_iterations
        self.name = name or self.__class__.__name__
        self.description = description
        self.history_policy = history_policy or self._default_history_policy()
        self.final_result_policy = final_result_policy

        # Runtime state
        self.shared_messages: List[Message] = []
        self.iteration_count = 0
        self.start_time: Optional[float] = None
        self._last_termination_check_count = 0
        self._last_context_size = 0
        self._last_context_was_text = False

        # Validate agent names are unique
        names = [agent.name for agent in agents]
        if len(names) != len(set(names)):
            raise ValueError("Agent names must be unique")

    async def run(
        self,
        task: Union[str, UserMessage, List[Message]],
        cancellation_token: Optional[CancellationToken] = None,
        context: Optional[AgentContext] = None,
    ) -> OrchestrationResponse:
        """
        Execute the orchestration pattern.

        Args:
            task: The new task to orchestrate (string, UserMessage or message list)
            cancellation_token: Optional cancellation token
            context: Optional session context. When provided, shared_messages is
                seeded from context.messages (prior history) before the run starts,
                and context.messages is updated with the full conversation at the end.

        Returns:
            OrchestrationResponse with messages, usage, and metadata

        Raises:
            asyncio.CancelledError: If cancellation is requested
        """
        self._reset_for_run()

        final_result = None

        try:
            async for item in self.run_stream(task, cancellation_token, context=context):
                if isinstance(item, OrchestrationResponse):
                    final_result = item

            return final_result or self._create_fallback_result("No result produced")

        except asyncio.CancelledError:
            raise
        except Exception as e:
            elapsed_time = int((time.time() - (self.start_time or time.time())) * 1000)
            return OrchestrationResponse(
                messages=self.shared_messages,
                final_result=f"Orchestration failed: {str(e)}",
                usage=Usage(duration_ms=elapsed_time),
                stop_message=StopMessage(
                    content=f"Error: {str(e)}", source="Exception"
                ),
                pattern_metadata=self._get_pattern_metadata(),
            )

    async def run_stream(
        self,
        task: Union[str, UserMessage, List[Message]],
        cancellation_token: Optional[CancellationToken] = None,
        verbose: bool = False,
        context: Optional[AgentContext] = None,
    ) -> AsyncGenerator[
        Union[Message, OrchestrationEvent, OrchestrationResponse], None
    ]:
        """
        Execute orchestration with streaming output.

        This implements the universal orchestration loop from Chapter 7.1.2:
        1. Setup: Reset state, normalize task, check initial termination
        2. Main loop: Select → Prepare → Execute → Update → Check termination
        3. Completion: Aggregate usage, emit final response

        Args:
            task: The new task to orchestrate (string, UserMessage or message list)
            cancellation_token: Optional cancellation token
            verbose: If True, emit orchestration events
            context: Optional session context — see run() for details.

        Yields:
            Messages, events (if verbose=True), and final OrchestrationResponse
        """
        # Check if OTEL tracing is enabled
        if os.getenv("AGENTBYTE_ENABLE_OTEL", "false").lower() in ("true", "1", "yes"):
            try:
                from opentelemetry import trace

                tracer = trace.get_tracer("agentbyte.orchestration")
                with tracer.start_as_current_span(
                    f"orchestration {self.__class__.__name__}"
                ) as span:
                    # Set span attributes
                    span.set_attribute(
                        "agentbyte.orchestration.pattern", self.__class__.__name__
                    )
                    span.set_attribute(
                        "agentbyte.orchestration.agent_count", len(self.agents)
                    )
                    span.set_attribute(
                        "agentbyte.orchestration.max_iterations", self.max_iterations
                    )
                    if self.name:
                        span.set_attribute("agentbyte.orchestration.name", self.name)

                    # Execute with tracing
                    async for item in self._run_stream_impl(
                        task, cancellation_token, verbose, context
                    ):
                        yield item
                return
            except Exception:
                # Fallback to non-traced execution if OTEL fails
                pass

        # Non-traced execution
        async for item in self._run_stream_impl(task, cancellation_token, verbose, context):
            yield item

    async def _run_stream_impl(
        self,
        task: Union[str, UserMessage, List[Message]],
        cancellation_token: Optional[CancellationToken] = None,
        verbose: bool = False,
        context: Optional[AgentContext] = None,
    ) -> AsyncGenerator[
        Union[Message, OrchestrationEvent, OrchestrationResponse], None
    ]:
        """Internal implementation of run_stream (separated for OTEL wrapping)."""
        # Reset state for new run
        self._reset_for_run()
        self.start_time = time.time()

        # Seed shared_messages from prior session history when context is provided
        if context is not None and context.messages:
            self.shared_messages.extend(list(context.messages))

        # Initialize stop_message
        stop_message: Optional[StopMessage] = None
        # Track all messages streamed to user for accurate termination counting
        streamed_messages: List[Message] = []
        # Track agent usage statistics for aggregation
        agent_usage_stats: List[Usage] = []

        try:
            # Emit orchestration start event
            if verbose:
                yield OrchestrationStartEvent(
                    source="orchestrator",
                    task=str(task),
                    pattern=self.__class__.__name__,
                )

            # Normalize task to initial messages
            initial_messages = self._normalize_task_to_messages(task)
            self.shared_messages.extend(initial_messages)

            # Yield initial messages
            for message in initial_messages:
                yield message
                streamed_messages.append(message)

            # Initialize termination with initial messages
            self.termination.check(initial_messages)
            # Update termination check counter after initial check
            self._last_termination_check_count = len(streamed_messages)

            # Universal orchestration loop
            while self.iteration_count < self.max_iterations:
                # Check for cancellation at the start of each iteration
                if cancellation_token and cancellation_token.is_cancelled():
                    raise asyncio.CancelledError()

                # Check termination BEFORE processing next agent (but not on first iteration)
                if self.iteration_count > 0 and self.termination.is_met():
                    stop_message = StopMessage(
                        content=self.termination.get_reason(),
                        source=self.termination.__class__.__name__,
                        metadata=self.termination.get_metadata(),
                    )
                    break

                # 1. Select next agent (pattern-specific logic)
                next_agent = await self.select_next_agent()

                if verbose:
                    yield AgentSelectionEvent(
                        source="orchestrator",
                        selected_agent=next_agent.name,
                        selection_reason=self._get_selection_reason(next_agent),
                    )

                # 2. Prepare context for agent (pattern-specific)
                context_payload = await self.prepare_context_for_agent(next_agent)
                context_size = self._get_context_size(context_payload)
                if verbose:
                    yield AgentExecutionStartEvent(
                        source="orchestrator",
                        executing_agent=next_agent.name,
                        context_size=context_size,
                    )

                # 3. Execute agent with streaming support and cancellation
                agent_messages = []
                result: Optional[AgentResponse] = None

                try:
                    async for item in next_agent.run_stream(
                        self._normalize_context_payload(context_payload),
                        cancellation_token=cancellation_token,
                        verbose=verbose,
                    ):
                        # Check for cancellation during agent execution
                        if cancellation_token and cancellation_token.is_cancelled():
                            raise asyncio.CancelledError()

                        # Type guard for Message (has content and role attributes)
                        if hasattr(item, "content") and hasattr(item, "role"):
                            # This is a Message - collect it but only forward non-user messages
                            message_item = cast(Message, item)
                            agent_messages.append(message_item)

                            # Don't stream UserMessages - they're just context we sent to agents
                            if not isinstance(message_item, UserMessage):
                                yield message_item
                                streamed_messages.append(message_item)
                        elif hasattr(item, "messages") and hasattr(item, "usage"):
                            # This is an AgentResponse - store it but don't forward
                            result = cast(AgentResponse, item)
                        # Note: Other agent events are not forwarded to maintain type safety

                except asyncio.CancelledError:
                    # Handle cancellation gracefully
                    if verbose:
                        yield AgentExecutionCompleteEvent(
                            source="orchestrator",
                            executing_agent=next_agent.name,
                            success=False,
                            message_count=len(agent_messages),
                        )
                    raise

                # Check for cancellation again after agent execution
                if cancellation_token and cancellation_token.is_cancelled():
                    raise asyncio.CancelledError()

                # Ensure we have a result
                if result is None:
                    # Create fallback result from collected messages
                    fallback_context = AgentContext()
                    fallback_context.messages = agent_messages

                    result = AgentResponse(
                        source=next_agent.name,
                        context=fallback_context,
                        usage=Usage(duration_ms=0, llm_calls=0),
                        finish_reason="completed_without_response",
                    )

                # Collect agent usage statistics for aggregation
                agent_usage_stats.append(result.usage)

                # Type guard: ensure result is AgentResponse
                assert hasattr(result, "messages"), (
                    "Result must be AgentResponse with messages"
                )

                if verbose:
                    yield AgentExecutionCompleteEvent(
                        source="orchestrator",
                        executing_agent=next_agent.name,
                        success=True,
                        message_count=len(result.messages),
                    )

                # 4. Update shared state (pattern-specific)
                await self.update_shared_state(result)

                pattern_stop_message = self._get_pattern_stop_message()
                if pattern_stop_message is not None:
                    stop_message = pattern_stop_message
                    break

                # 5. Check termination with new messages streamed in this iteration
                # Calculate messages streamed since last termination check
                new_streamed_messages = streamed_messages[
                    self._last_termination_check_count :
                ]
                self._last_termination_check_count = len(streamed_messages)

                stop_message = self.termination.check(new_streamed_messages)
                if stop_message:
                    break

                self.iteration_count += 1

            # Handle max iterations reached
            if self.iteration_count >= self.max_iterations and stop_message is None:
                stop_message = StopMessage(
                    content=f"Maximum iterations reached ({self.max_iterations})",
                    source="MaxIterations",
                )

            # Ensure we always have a stop_message
            if stop_message is None:
                stop_message = StopMessage(
                    content="Orchestration completed normally", source="Completion"
                )

            # Emit orchestration complete event
            final_result = self._generate_final_result()
            if verbose:
                yield OrchestrationCompleteEvent(
                    source="orchestrator",
                    result=final_result,
                    stop_reason=stop_message.content,
                )

            # Calculate usage statistics
            elapsed_time = int((time.time() - self.start_time) * 1000)

            # Aggregate usage from all agent executions
            total_usage = Usage(duration_ms=elapsed_time)
            for agent_usage in agent_usage_stats:
                total_usage = total_usage + agent_usage
            for extra_usage in self._get_additional_usage_stats():
                total_usage = total_usage + extra_usage

            # Yield final OrchestrationResponse
            orchestration_result = OrchestrationResponse(
                messages=self.shared_messages,
                final_result=final_result,
                usage=total_usage,
                stop_message=stop_message,
                pattern_metadata=self._get_pattern_metadata(),
            )

            # Update OTEL span with final metrics if tracing
            if os.getenv("AGENTBYTE_ENABLE_OTEL", "false").lower() in (
                "true",
                "1",
                "yes",
            ):
                try:
                    from opentelemetry import trace

                    span = trace.get_current_span()
                    if span and span.is_recording():
                        span.set_attribute(
                            "agentbyte.orchestration.iterations", self.iteration_count
                        )
                        span.set_attribute(
                            "agentbyte.orchestration.message_count",
                            len(self.shared_messages),
                        )
                        span.set_attribute(
                            "agentbyte.orchestration.stop_reason", stop_message.content
                        )
                        span.set_attribute(
                            "agentbyte.orchestration.stop_source", stop_message.source
                        )
                        if hasattr(total_usage, "total_tokens"):
                            span.set_attribute(
                                "gen_ai.usage.total_tokens", total_usage.total_tokens
                            )
                        if hasattr(total_usage, "duration_ms"):
                            span.set_attribute(
                                "agentbyte.orchestration.duration_ms",
                                total_usage.duration_ms,
                            )
                except Exception:
                    pass

            # Write final conversation back to session context
            if context is not None:
                context.messages = list(self.shared_messages)

            yield orchestration_result

        except asyncio.CancelledError:
            # Handle cancellation at orchestration level
            elapsed_time = int((time.time() - (self.start_time or time.time())) * 1000)

            if verbose:
                yield OrchestrationCompleteEvent(
                    source="orchestrator",
                    result="Orchestration cancelled",
                    stop_reason="Cancellation",
                )

            # Aggregate usage from all agent executions before cancellation
            total_usage = Usage(duration_ms=elapsed_time)
            for agent_usage in agent_usage_stats:
                total_usage = total_usage + agent_usage
            for extra_usage in self._get_additional_usage_stats():
                total_usage = total_usage + extra_usage

            cancellation_result = OrchestrationResponse(
                messages=self.shared_messages,
                final_result="Orchestration was cancelled",
                usage=total_usage,
                stop_message=StopMessage(
                    content="Orchestration cancelled", source="CancellationToken"
                ),
                pattern_metadata=self._get_pattern_metadata(),
            )

            # Write partial conversation back to session context on cancellation
            if context is not None:
                context.messages = list(self.shared_messages)

            # Update OTEL span with cancellation status
            if os.getenv("AGENTBYTE_ENABLE_OTEL", "false").lower() in (
                "true",
                "1",
                "yes",
            ):
                try:
                    from opentelemetry import trace
                    from opentelemetry.trace import Status, StatusCode

                    span = trace.get_current_span()
                    if span and span.is_recording():
                        span.set_status(Status(StatusCode.ERROR, "Cancelled"))
                        span.set_attribute("agentbyte.orchestration.cancelled", True)
                except Exception:
                    pass

            yield cancellation_result

            # Re-raise for proper cancellation handling
            raise

    @abstractmethod
    async def select_next_agent(self) -> BaseAgent:
        """
        Pattern-specific agent selection logic.

        Returns:
            The agent that should execute next

        Examples:
            - RoundRobin: Cycle through agents in order
            - AI-driven: Use LLM to select based on context
            - Plan-based: Follow pre-generated execution plan
        """
        pass

    @abstractmethod
    async def prepare_context_for_agent(
        self, agent: BaseAgent
    ) -> Union[str, UserMessage, List[Message]]:
        """
        Pattern-specific context preparation.

        Args:
            agent: The agent that will receive this context

        Returns:
            Context payload to send to the agent

        Examples:
            - RoundRobin: Format full conversation history as string
            - AI-driven: Curate relevant context based on agent role
            - Plan-based: Include plan step + retry instructions
        """
        pass

    @abstractmethod
    async def update_shared_state(self, result: AgentResponse) -> None:
        """
        Pattern-specific state update after agent execution.

        Args:
            result: The AgentResponse from the just-executed agent

        Examples:
            - RoundRobin: Append all new messages to shared_messages
            - AI-driven: Filter/transform messages based on relevance
            - Plan-based: Evaluate step completion, update retry state
        """
        pass

    def _normalize_task_to_messages(
        self, task: Union[str, UserMessage, List[Message]]
    ) -> List[Message]:
        """Convert task input to list of messages."""
        if isinstance(task, str):
            return [UserMessage(content=task, source="user")]
        elif isinstance(task, UserMessage):
            return [task]
        elif isinstance(task, list):
            return task
        else:
            raise ValueError(f"Unsupported task type: {type(task)}")

    def _normalize_context_payload(
        self, context: Union[str, UserMessage, List[Message]]
    ) -> Union[UserMessage, List[Message]]:
        """Convert pattern context into a valid agent task payload."""
        if isinstance(context, str):
            return UserMessage(content=context, source="orchestrator")
        return context

    def _get_context_size(self, context: Union[str, UserMessage, List[Message]]) -> int:
        """Return the number of context items for event metadata."""
        if isinstance(context, list):
            return len(context)
        return 1

    def _reset_for_run(self) -> None:
        """Reset orchestrator state for new run."""
        self.shared_messages = []
        self.iteration_count = 0
        self.start_time = None
        self.termination.reset()
        self._last_termination_check_count = 0
        self._last_context_size = 0
        self._last_context_was_text = False

    def _generate_final_result(self) -> str:
        """Generate final result using FinalResultPolicy if configured."""
        if not self.shared_messages:
            return "No messages generated"
        policy = self.final_result_policy
        candidate = self._select_final_message(policy)
        if candidate is None:
            return "Task completed"
        content = candidate.content or ""
        if policy and policy.strip_signals:
            content = self._strip_signals(content, policy.strip_signals, policy.strip_mode)
        return content.strip()

    def _select_final_message(self, policy: FinalResultPolicy | None) -> Message | None:
        """Select the best final message according to policy preference."""
        prefer = policy.prefer_agent.casefold() if policy and policy.prefer_agent else None
        for message in reversed(self.shared_messages):
            if not (hasattr(message, "role") and message.role == "assistant"):
                continue
            if prefer is None:
                return message
            if getattr(message, "source", "").casefold() == prefer:
                return message
        # fallback: any assistant message when preferred agent has no match
        if prefer is not None:
            for message in reversed(self.shared_messages):
                if hasattr(message, "role") and message.role == "assistant":
                    return message
        return None

    def _strip_signals(self, content: str, signals: list[str], mode: str) -> str:
        """Strip control signals from message content."""
        if mode == "trailing":
            lines = content.rstrip().splitlines()
            if lines and lines[-1].strip().upper() in {s.upper() for s in signals}:
                lines = lines[:-1]
            return "\n".join(lines).rstrip()
        # mode == "anywhere"
        for signal in signals:
            content = re.sub(re.escape(signal), "", content, flags=re.IGNORECASE)
        return content

    def get_agent_capabilities_summary(self) -> str:
        """
        Get agent capabilities summary for LLM consumption.

        Used by AI-driven and plan-based orchestrators to help LLM
        understand available agents and their capabilities.
        """
        summary_lines = []
        for agent in self.agents:
            line = f"- {agent.name}: {agent.description}"

            if hasattr(agent, "tools") and agent.tools:
                tool_names = [
                    tool.name if hasattr(tool, "name") else str(tool)[:20]
                    for tool in agent.tools
                ]
                if tool_names:
                    line += f" | Tools: {', '.join(tool_names)}"

            summary_lines.append(line)

        return "\n".join(summary_lines)

    def _get_pattern_metadata(self) -> Dict[str, Any]:
        """
        Get pattern-specific metadata for result.

        Subclasses should override and extend this to add custom metrics.
        """
        return {
            "pattern": self.__class__.__name__,
            "iterations_completed": self.iteration_count,
            "agents_count": len(self.agents),
            "message_count": len(self.shared_messages),
        }

    def _get_selection_reason(self, selected_agent: BaseAgent) -> str:
        """Get selection reason for AgentSelectionEvent emission."""
        return f"Iteration {self.iteration_count + 1}"

    def _get_additional_usage_stats(self) -> List[Usage]:
        """Get non-agent usage stats that should be aggregated in final totals."""
        return []

    def _get_pattern_stop_message(self) -> Optional[StopMessage]:
        """Allow patterns to stop without relying on a termination condition."""
        return None

    def _create_fallback_result(self, reason: str) -> OrchestrationResponse:
        """Create fallback result for error cases."""
        elapsed_time = int((time.time() - (self.start_time or time.time())) * 1000)

        return OrchestrationResponse(
            messages=self.shared_messages,
            final_result=reason,
            usage=Usage(duration_ms=elapsed_time),
            stop_message=StopMessage(content=reason, source="Fallback"),
            pattern_metadata=self._get_pattern_metadata(),
        )

    def _default_history_policy(self) -> HistoryContextPolicy:
        """Return the default execution-agent history policy."""
        return HistoryContextPolicy(
            default=AgentHistoryPolicy(window="full", format="text")
        )

    def _get_effective_history_policy(self, agent: BaseAgent) -> AgentHistoryPolicy:
        """Resolve the per-agent history policy using exact then casefold matching."""
        if agent.name in self.history_policy.per_agent:
            return self.history_policy.per_agent[agent.name]

        target = agent.name.casefold()
        for agent_name, policy in self.history_policy.per_agent.items():
            if agent_name.casefold() == target:
                return policy

        return self.history_policy.default

    def _select_history_messages(self, policy: AgentHistoryPolicy) -> list[Message]:
        """Return the relevant shared-message slice for a history policy."""
        if policy.window == "recent" and policy.message_limit is not None:
            return list(self.shared_messages[-policy.message_limit :])
        return list(self.shared_messages)

    def _build_agent_context_from_history(
        self, agent: BaseAgent
    ) -> UserMessage | list[Message]:
        """Build the execution context payload for an agent from policy + history."""
        policy = self._get_effective_history_policy(agent)
        messages = self._select_history_messages(policy)
        return self._render_history_messages(messages, policy)

    def _render_history_messages(
        self, messages: list[Message], policy: AgentHistoryPolicy
    ) -> UserMessage | list[Message]:
        """Render history as raw messages or a single textual user message."""
        if policy.format == "messages":
            self._last_context_size = len(messages)
            self._last_context_was_text = False
            return list(messages)

        formatters = {
            "text": self._format_history_as_text,
            "markdown": self._format_history_as_markdown,
            "json": self._format_history_as_json,
        }
        formatter = formatters[policy.format]
        content = formatter(messages)
        if policy.include_turn_prompt:
            if content:
                content = f"{content}\n\nIt is now your turn."
            else:
                content = "It is now your turn."

        self._last_context_size = 1
        self._last_context_was_text = True
        return UserMessage(content=content, source="orchestrator")

    def _format_history_as_text(self, messages: list[Message]) -> str:
        """Format a list of messages as a simple text transcript."""
        if not messages:
            return (
                "You are part of a team taking turns to collaboratively address a task."
            )

        lines = [
            "You are part of a team taking turns to collaboratively address a task.",
            "Here's the progress/history so far:",
            "",
        ]
        for message in messages:
            lines.append(self._format_message_line(message))
        return "\n".join(lines).strip()

    def _format_history_as_markdown(self, messages: list[Message]) -> str:
        """Format a list of messages as a markdown transcript."""
        if not messages:
            return (
                "# Team Context\n\n"
                "You are part of a team taking turns to collaboratively address a task."
            )

        lines = [
            "# Team Context",
            "",
            "You are part of a team taking turns to collaboratively address a task.",
            "",
        ]
        for message in messages:
            lines.extend(
                [
                    f"## {message.source} ({getattr(message, 'role', 'unknown')})",
                    message.content,
                    "",
                ]
            )
        return "\n".join(lines).strip()

    def _format_history_as_json(self, messages: list[Message]) -> str:
        """Format a list of messages as JSON for structured agent consumption."""
        payload = {
            "team_context": [
                {
                    "source": message.source,
                    "role": getattr(message, "role", "unknown"),
                    "content": message.content,
                }
                for message in messages
            ]
        }
        return json.dumps(payload, indent=2)

    def _format_message_line(self, message: Message) -> str:
        """Format a single message as a compact transcript line."""
        role = getattr(message, "role", "unknown")
        return f"- {message.source} ({role}): {message.content}"
