"""Canonical Internal Representation (IR) for EB1 request/response handling.

This module provides a single source of truth for request normalization,
tool filtering, and format conversion. All ingress paths (Responses, Chat
Completions, Anthropic Messages) normalize into the IR, and all egress paths
render from the IR.

Design Principles:
- Canonicalize early, de-canonicalize late
- Make lossiness explicit and reject incompatible requests
- Apply tool filtering once at IR construction
- Separate debug state from user payloads

See: docs/design/eb1_format_swap_contract.md
"""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Literal

from ember.models.providers.tool_filtering import SideEffects, get_side_effects


@dataclass(frozen=True)
class FunctionCallItem:
    """First-class function call item (tool invocation).

    Represents a tool call request from the assistant, independent of the
    message structure. This enables format-agnostic handling of tool calls
    across OpenAI, Anthropic, and other providers.

    Attributes:
        call_id: Unique identifier for this tool call.
        name: Name of the function to call.
        arguments: JSON string of function arguments.
    """

    call_id: str
    name: str
    arguments: str  # JSON string, validated by invariants


@dataclass(frozen=True)
class FunctionCallOutputItem:
    """First-class function call output (tool result).

    Represents the result of a tool execution, linked to the original
    call by call_id. Must reference a prior FunctionCallItem.

    Attributes:
        call_id: ID of the FunctionCallItem this responds to.
        output: String output from the tool execution.
    """

    call_id: str
    output: str


@dataclass(frozen=True)
class MessageItem:
    """Standard message with role and content blocks.

    Represents a conversation turn that may contain multiple content blocks
    (text, images, etc.) but not inline tool calls. Tool calls are represented
    as separate FunctionCallItem instances.

    Attributes:
        role: Message role (user, assistant, system).
        content: Tuple of content blocks.
        status: Optional status (e.g., "completed", "in_progress").
    """

    role: Literal["user", "assistant", "system"]
    content: tuple["ContentBlock", ...]
    status: str | None = None


# Union type for all conversation items
ConversationItem = MessageItem | FunctionCallItem | FunctionCallOutputItem


class ToolPolicy(Enum):
    """Explicit tool access policy applied at IR construction.

    Determines which tools are available during execution:
    - NONE: No tools available (tool_choice="none" or empty tools)
    - READ_ONLY: Only tools with x-side-effects: "none" (for EB1 workers)
    - FULL: All tools preserved (for EB1 judge, direct calls)
    - JUDGE_ONLY: Skip workers entirely, judge gets full tools (EB1-A with tools)
    """

    NONE = "none"
    READ_ONLY = "read_only"
    FULL = "full"
    JUDGE_ONLY = "judge_only"


@dataclass(frozen=True)
class ToolChoiceAuto:
    """Model decides whether to use tools (default behavior)."""

    disable_parallel: bool = False


@dataclass(frozen=True)
class ToolChoiceNone:
    """Explicitly disable tool use."""

    pass


@dataclass(frozen=True)
class ToolChoiceRequired:
    """Model must use at least one tool."""

    pass


@dataclass(frozen=True)
class ToolChoiceForced:
    """Force a specific tool by name."""

    name: str


# Type alias for the canonical tool_choice discriminated union.
# None means "use provider default" (usually auto).
CanonicalToolChoice = ToolChoiceAuto | ToolChoiceNone | ToolChoiceRequired | ToolChoiceForced | None


class TargetFormat(Enum):
    """Target format for rendering IR responses."""

    RESPONSES = "responses"
    CHAT_COMPLETIONS = "chat_completions"
    ANTHROPIC_MESSAGES = "anthropic_messages"


@dataclass
class ContentBlock:
    """A content block in the IR.

    Content blocks represent the structured content within messages and responses.
    The type field determines the block's semantics:
    - input_text: User input text
    - output_text: Model output text
    - tool_use: Tool invocation request
    - tool_result: Result from tool execution
    - reasoning_summary: Summarized reasoning
    - image: Image content (base64 or URL)
    """

    type: Literal[
        "input_text",
        "output_text",
        "tool_use",
        "tool_result",
        "reasoning_summary",
        "image",
    ]
    content: str | dict[str, Any] | None = None
    tool_call_id: str | None = None
    tool_name: str | None = None
    tool_arguments: dict[str, Any] | None = None


@dataclass
class Message:
    """A message in the IR conversation history."""

    role: Literal["user", "assistant", "system", "tool"]
    content: str | list[ContentBlock] | None = None
    tool_calls: list[dict[str, Any]] | None = None
    tool_call_id: str | None = None
    name: str | None = None


@dataclass
class Tool:
    """A normalized tool definition in Responses API format."""

    type: Literal["function"] = "function"
    name: str | None = None
    description: str | None = None
    parameters: dict[str, Any] | None = None
    strict: bool | None = None
    side_effects: SideEffects = SideEffects.UNKNOWN

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for serialization."""
        result: dict[str, Any] = {"type": self.type}
        if self.name is not None:
            result["name"] = self.name
        if self.description is not None:
            result["description"] = self.description
        if self.parameters is not None:
            result["parameters"] = self.parameters
        if self.strict is not None:
            result["strict"] = self.strict
        if self.side_effects != SideEffects.UNKNOWN:
            result["x-side-effects"] = self.side_effects.value
        return result


@dataclass
class ProviderTool:
    """A provider-native tool (web_search, code_interpreter, file_search, etc.).

    These tools are passed through to providers that support them,
    and rejected with NormalizationError for providers that don't.
    """

    type: str  # e.g., "web_search", "code_interpreter", "file_search"
    config: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for serialization."""
        result: dict[str, Any] = {"type": self.type}
        result.update(self.config)
        return result


# Type alias for the canonical tool discriminated union.
CanonicalTool = Tool | ProviderTool


@dataclass
class EB1Request:
    """Canonical internal representation for EB1 requests.

    This dataclass is the single format used inside routing, planning,
    and EB1 execution paths. All ingress formats (Responses, Chat Completions,
    Anthropic Messages) normalize into this structure.
    """

    model: str
    input: str | list[ContentBlock] | None = None
    instructions: str | None = None
    messages: list[Message] | None = None
    tools: list[CanonicalTool] = field(default_factory=list)
    tool_choice: CanonicalToolChoice = None
    tool_policy: ToolPolicy = ToolPolicy.FULL
    parallel_tool_calls: bool = True
    response_format: dict[str, Any] | None = None
    reasoning: dict[str, Any] | None = None
    metadata: dict[str, Any] | None = None
    stream: bool = False
    max_output_tokens: int | None = None
    temperature: float | None = None
    top_p: float | None = None


@dataclass
class EB1Response:
    """Canonical internal representation for EB1 responses."""

    id: str
    model: str
    output: list[ContentBlock] = field(default_factory=list)
    usage: dict[str, int] | None = None
    metadata: dict[str, Any] | None = None
    debug_metadata: dict[str, Any] | None = None


class NormalizationError(Exception):
    """Raised when tool or payload normalization fails."""

    def __init__(self, message: str, context: dict[str, Any] | None = None) -> None:
        super().__init__(message)
        self.context = context or {}


def normalize_tool(tool: Mapping[str, Any], *, model: str = "") -> CanonicalTool:
    """Normalize Chat Completions or Responses tool format to canonical IR format.

    The Responses API uses a flattened structure where name, description,
    and parameters are at the top level. The Chat Completions API nests
    these under a "function" key.

    This function accepts both formats and normalizes to the canonical Tool
    dataclass for function tools. Non-function tool types (web_search,
    code_interpreter, file_search, etc.) are normalized to ProviderTool.

    Args:
        tool: Tool definition in either format.
        model: Model name for error context.

    Returns:
        Normalized Tool dataclass for function tools, or ProviderTool
        for non-function tool types.

    Raises:
        NormalizationError: If tool structure is invalid.
    """
    if not isinstance(tool, Mapping):
        raise NormalizationError(
            "Tool definition must be a mapping",
            context={"model": model},
        )

    # Start with a mutable copy
    tool_dict = dict(tool)

    # Convert non-function tool types to ProviderTool
    tool_type = tool_dict.get("type")
    if tool_type is not None and tool_type != "function":
        config = {k: v for k, v in tool_dict.items() if k != "type"}
        return ProviderTool(type=tool_type, config=config)

    # Detect Chat Completions format: {"type": "function", "function": {...}}
    if tool_dict.get("type") == "function" and "function" in tool_dict:
        func = tool_dict.get("function")
        if not isinstance(func, Mapping):
            raise NormalizationError(
                "Tool 'function' field must be a mapping",
                context={"model": model},
            )
        # Lift function fields without clobbering explicit top-level values.
        # Top-level fields take precedence, allowing callers to override nested defaults.
        for key in ("name", "description", "parameters", "strict"):
            if key in func and key not in tool_dict:
                tool_dict[key] = func[key]
            elif key not in tool_dict:
                tool_dict[key] = func.get(key)

        # Also check for x-side-effects in the function object
        if "x-side-effects" in func and "x-side-effects" not in tool_dict:
            tool_dict["x-side-effects"] = func["x-side-effects"]

    # Extract side effects annotation
    side_effects = get_side_effects(tool_dict)

    return Tool(
        type="function",
        name=tool_dict.get("name"),
        description=tool_dict.get("description"),
        parameters=tool_dict.get("parameters"),
        strict=tool_dict.get("strict"),
        side_effects=side_effects,
    )


def normalize_tools(
    tools: Sequence[Mapping[str, Any]] | None,
    *,
    model: str = "",
) -> list[CanonicalTool]:
    """Normalize a list of tools to canonical IR format.

    Args:
        tools: List of tool definitions in any supported format.
        model: Model name for error context.

    Returns:
        List of normalized Tool dataclasses for function tools, or ProviderTool
        for non-function tool types (web_search, code_interpreter, etc.).
    """
    if not tools:
        return []

    return [normalize_tool(tool, model=model) for tool in tools]


def normalize_tool_choice(
    tool_choice: str | Mapping[str, Any] | None,
) -> CanonicalToolChoice:
    """Normalize tool_choice to canonical form.

    Handles both nested Chat Completions and flat Responses formats:
    - Nested: {"type": "function", "function": {"name": "X"}}
    - Flat: {"type": "function", "name": "X"}

    Args:
        tool_choice: Raw tool_choice from request payload.

    Returns:
        Canonical ToolChoice dataclass or None for default behavior.

    Raises:
        NormalizationError: If tool_choice structure is invalid.
    """
    if tool_choice is None:
        return None

    if isinstance(tool_choice, str):
        if tool_choice == "auto":
            return ToolChoiceAuto()
        if tool_choice == "none":
            return ToolChoiceNone()
        if tool_choice == "required":
            return ToolChoiceRequired()
        raise NormalizationError(f"Unknown tool_choice string: {tool_choice}")

    # Dict form - check both nested and flat formats
    if not isinstance(tool_choice, Mapping):
        raise NormalizationError(
            "tool_choice must be string or mapping",
            context={"tool_choice": tool_choice},
        )

    choice_type = tool_choice.get("type")
    if choice_type == "function":
        # Flat format: {"type": "function", "name": "X"}
        name = tool_choice.get("name")
        # Nested format: {"type": "function", "function": {"name": "X"}}
        if not name:
            func = tool_choice.get("function", {})
            name = func.get("name") if isinstance(func, Mapping) else None
        if name:
            return ToolChoiceForced(name=str(name))
        raise NormalizationError(
            "tool_choice function type requires name",
            context={"tool_choice": dict(tool_choice)},
        )

    # Unknown dict format - pass through for provider-specific handling
    # This allows Anthropic-native formats like {"type": "tool", "name": "X"}
    return None


def tool_choice_to_dict(
    choice: CanonicalToolChoice,
    target_format: "TargetFormat",
) -> str | dict[str, Any] | None:
    """Serialize CanonicalToolChoice for the target format.

    Args:
        choice: Canonical tool choice to serialize.
        target_format: The target API format.

    Returns:
        Serialized tool_choice in the target format.
    """
    if choice is None:
        return None

    if isinstance(choice, ToolChoiceAuto):
        if target_format == TargetFormat.ANTHROPIC_MESSAGES:
            result: dict[str, Any] = {"type": "auto"}
            if choice.disable_parallel:
                result["disable_parallel_tool_use"] = True
            return result
        return "auto"

    if isinstance(choice, ToolChoiceNone):
        if target_format == TargetFormat.ANTHROPIC_MESSAGES:
            return {"type": "none"}
        return "none"

    if isinstance(choice, ToolChoiceRequired):
        if target_format == TargetFormat.ANTHROPIC_MESSAGES:
            return {"type": "any"}
        return "required"

    if isinstance(choice, ToolChoiceForced):
        if target_format == TargetFormat.ANTHROPIC_MESSAGES:
            return {"type": "tool", "name": choice.name}
        # Responses/Chat Completions flat format
        return {"type": "function", "name": choice.name}

    return None


def apply_tool_policy(tools: list[CanonicalTool], policy: ToolPolicy) -> list[CanonicalTool]:
    """Apply tool filtering policy to a list of tools.

    This function is called once at IR construction, replacing scattered
    filter_tools_for_worker() and filter_tools_for_judge() calls.

    Non-function tools (ProviderTool) are only retained for FULL/JUDGE_ONLY
    policies. READ_ONLY excludes them since side effects are not annotated.

    Args:
        tools: List of normalized Tool or ProviderTool objects.
        policy: The tool access policy to apply.

    Returns:
        Filtered list of tools based on policy.
    """
    if policy == ToolPolicy.NONE:
        return []

    if policy == ToolPolicy.READ_ONLY:
        # Only tools with x-side-effects: "none" are allowed for workers.
        # ProviderTool objects are excluded since we can't determine their side effects.
        return [t for t in tools if isinstance(t, Tool) and t.side_effects == SideEffects.NONE]

    # FULL and JUDGE_ONLY both return all tools
    return tools


def tools_to_dict_list(tools: list[CanonicalTool]) -> list[dict[str, Any]]:
    """Convert Tool/ProviderTool list to list of dicts for serialization."""
    return [t.to_dict() for t in tools]


def _require_function_tools(
    tools: list[CanonicalTool],
    *,
    model: str,
    target_format: str,
) -> list[Tool]:
    """Extract only function tools, raising if non-function tools present."""
    non_function_types: list[str] = []
    function_tools: list[Tool] = []
    for tool in tools:
        if isinstance(tool, Tool):
            function_tools.append(tool)
        elif isinstance(tool, ProviderTool):
            non_function_types.append(tool.type)

    if non_function_types:
        raise NormalizationError(
            f"Non-function tools are not supported in {target_format} format",
            context={"model": model, "tool_types": non_function_types},
        )

    return function_tools


def is_ensemble_variant(model: str) -> bool:
    """Check if a model is an ensemble EB1 variant that doesn't support streaming tools.

    Ensemble variants (eb1, eb1-pro, eb1-pro-high, eb1-fast) don't surface worker
    tool calls to clients, so streaming with tools is not supported.

    Args:
        model: Model identifier.

    Returns:
        True if this is an ensemble variant that should reject streaming with tools.
    """
    ensemble_variants = {
        "eb1",
        "eb1-pro",
        "eb1-pro-high",
        "eb1-fast",
    }
    # Normalize model name (handle provider prefixes like "takumi/eb1")
    model_name = model.split("/")[-1].lower() if "/" in model else model.lower()
    return model_name in ensemble_variants


class CapabilityConflict(Exception):
    """Raised when requested features exceed provider capabilities.

    Returns HTTP 409 to clients with a lossiness label explaining
    why the request cannot be fulfilled.
    """

    def __init__(self, label: str, message: str | None = None) -> None:
        self.label = label
        self.message = message or f"Capability conflict: {label}"
        super().__init__(self.message)


def validate_streaming_tools(request: EB1Request) -> None:
    """Validate that streaming with tools is supported for the model.

    Non-codex ensemble variants (eb1, eb1-pro, eb1-fast) don't surface worker
    tool calls to clients during streaming. Rather than silently dropping tool
    events, we fail closed with a capability conflict.

    Args:
        request: The normalized EB1 request.

    Raises:
        CapabilityConflict: If streaming with tools is not supported.
    """
    if request.stream and request.tools and is_ensemble_variant(request.model):
        raise CapabilityConflict(
            label="tools_not_streamable_in_ensemble",
            message=(
                f"Model {request.model} does not support streaming with tools. "
                "Ensemble variants don't surface worker tool calls. "
                "Use eb1-codex, eb1-a, or set stream=false."
            ),
        )


def normalize_to_ir(
    payload: dict[str, Any],
    *,
    source_format: Literal["responses", "chat_completions", "anthropic"] = "responses",
    tool_policy: ToolPolicy = ToolPolicy.FULL,
    model: str = "",
) -> EB1Request:
    """Normalize an incoming request payload to the canonical IR.

    This is the main ingress function. All request formats (Responses, Chat
    Completions, Anthropic Messages) are normalized into an EB1Request.

    Args:
        payload: The incoming request payload.
        source_format: The format of the incoming payload.
        tool_policy: The tool access policy to apply.
        model: Model name (used if not in payload).

    Returns:
        Normalized EB1Request.

    Raises:
        NormalizationError: If payload structure is invalid.
        CapabilityConflict: If requested features are unsupported.
    """
    # Extract model from payload or use provided
    resolved_model = payload.get("model", model)
    if not resolved_model:
        raise NormalizationError("Model is required", context={"source": source_format})

    # Normalize tools once
    raw_tools = payload.get("tools")
    normalized_tools = normalize_tools(raw_tools, model=resolved_model)

    # Apply tool policy once at IR construction
    filtered_tools = apply_tool_policy(normalized_tools, tool_policy)

    # Normalize tool_choice to canonical form
    raw_tool_choice = payload.get("tool_choice")
    normalized_choice = normalize_tool_choice(raw_tool_choice)
    if isinstance(normalized_choice, ToolChoiceNone):
        filtered_tools = []

    # Build the IR request
    request = EB1Request(
        model=resolved_model,
        input=payload.get("input"),
        instructions=payload.get("instructions"),
        messages=None,  # TODO: normalize messages from different formats
        tools=filtered_tools,
        tool_choice=normalized_choice,
        tool_policy=tool_policy,
        parallel_tool_calls=payload.get("parallel_tool_calls", True),
        response_format=payload.get("response_format"),
        reasoning=payload.get("reasoning"),
        metadata=payload.get("metadata"),
        stream=payload.get("stream", False),
        max_output_tokens=payload.get("max_output_tokens"),
        temperature=payload.get("temperature"),
        top_p=payload.get("top_p"),
    )

    # Validate streaming constraints
    validate_streaming_tools(request)

    return request


def render_from_ir(
    request: EB1Request,
    target_format: TargetFormat = TargetFormat.RESPONSES,
) -> dict[str, Any]:
    """Render an EB1Request to the target format.

    This is the main egress function for requests. The canonical IR is
    converted to the format expected by the target API.

    Args:
        request: The normalized EB1Request.
        target_format: The output format to render.

    Returns:
        Dictionary payload in the target format.
    """
    if target_format == TargetFormat.RESPONSES:
        return _render_to_responses(request)
    elif target_format == TargetFormat.CHAT_COMPLETIONS:
        return _render_to_chat_completions(request)
    elif target_format == TargetFormat.ANTHROPIC_MESSAGES:
        return _render_to_anthropic(request)
    else:
        raise ValueError(f"Unknown target format: {target_format}")


def _render_to_responses(request: EB1Request) -> dict[str, Any]:
    """Render IR to Responses API format."""
    payload: dict[str, Any] = {
        "model": request.model,
    }

    if request.input is not None:
        payload["input"] = request.input
    if request.instructions is not None:
        payload["instructions"] = request.instructions
    if request.tools:
        payload["tools"] = tools_to_dict_list(request.tools)
    serialized_choice = tool_choice_to_dict(request.tool_choice, TargetFormat.RESPONSES)
    if serialized_choice is not None:
        payload["tool_choice"] = serialized_choice
    if request.parallel_tool_calls is not None:
        payload["parallel_tool_calls"] = request.parallel_tool_calls
    if request.response_format is not None:
        payload["response_format"] = request.response_format
    if request.reasoning is not None:
        payload["reasoning"] = request.reasoning
    if request.metadata is not None:
        payload["metadata"] = request.metadata
    if request.stream:
        payload["stream"] = True
    if request.max_output_tokens is not None:
        payload["max_output_tokens"] = request.max_output_tokens
    if request.temperature is not None:
        payload["temperature"] = request.temperature
    if request.top_p is not None:
        payload["top_p"] = request.top_p

    return payload


def _render_to_chat_completions(request: EB1Request) -> dict[str, Any]:
    """Render IR to Chat Completions API format."""
    payload: dict[str, Any] = {
        "model": request.model,
    }

    # Convert input to messages format
    messages = []
    if request.instructions:
        messages.append({"role": "system", "content": request.instructions})
    if request.input:
        if isinstance(request.input, str):
            messages.append({"role": "user", "content": request.input})
        # TODO: Handle list of ContentBlocks
    payload["messages"] = messages

    # Convert tools to Chat Completions format (nested under function)
    if request.tools:
        function_tools = _require_function_tools(
            request.tools,
            model=request.model,
            target_format="chat_completions",
        )
        payload["tools"] = [
            {
                "type": "function",
                "function": {
                    "name": t.name,
                    "description": t.description,
                    "parameters": t.parameters,
                    **({"strict": t.strict} if t.strict is not None else {}),
                },
            }
            for t in function_tools
        ]
    serialized_choice = tool_choice_to_dict(request.tool_choice, TargetFormat.CHAT_COMPLETIONS)
    if serialized_choice is not None:
        payload["tool_choice"] = serialized_choice
    if request.response_format is not None:
        payload["response_format"] = request.response_format
    if request.stream:
        payload["stream"] = True
    if request.max_output_tokens is not None:
        payload["max_tokens"] = request.max_output_tokens
    if request.temperature is not None:
        payload["temperature"] = request.temperature
    if request.top_p is not None:
        payload["top_p"] = request.top_p

    return payload


def _render_to_anthropic(request: EB1Request) -> dict[str, Any]:
    """Render IR to Anthropic Messages API format."""
    payload: dict[str, Any] = {
        "model": request.model,
    }

    # Build messages
    messages = []
    if request.input:
        if isinstance(request.input, str):
            messages.append({"role": "user", "content": request.input})
    payload["messages"] = messages

    if request.instructions:
        payload["system"] = request.instructions

    # Convert tools to Anthropic format
    # As of Jan 2025, Anthropic requires explicit type field for tools.
    # Custom user-defined tools must have type="custom".
    if request.tools:
        function_tools = _require_function_tools(
            request.tools,
            model=request.model,
            target_format="anthropic_messages",
        )
        payload["tools"] = [
            {
                "type": "custom",
                "name": t.name,
                "description": t.description,
                "input_schema": t.parameters,
            }
            for t in function_tools
        ]
    serialized_choice = tool_choice_to_dict(
        request.tool_choice, TargetFormat.ANTHROPIC_MESSAGES
    )
    if serialized_choice is not None:
        payload["tool_choice"] = serialized_choice

    if request.stream:
        payload["stream"] = True
    if request.max_output_tokens is not None:
        payload["max_tokens"] = request.max_output_tokens
    if request.temperature is not None:
        payload["temperature"] = request.temperature
    if request.top_p is not None:
        payload["top_p"] = request.top_p

    return payload


class ConversationInvariantError(Exception):
    """Raised when conversation item invariants are violated."""

    def __init__(self, message: str, violations: list[str]) -> None:
        super().__init__(message)
        self.violations = violations


def validate_conversation_invariants(
    items: Sequence[ConversationItem],
) -> None:
    """Validate conversation item invariants.

    Ensures:
    1. Tool outputs reference prior tool calls (no dangling references)
    2. No duplicate call IDs
    3. Arguments in FunctionCallItem are valid JSON

    Args:
        items: Sequence of conversation items to validate.

    Raises:
        ConversationInvariantError: When invariants are violated.
    """
    import json as json_module

    violations: list[str] = []
    seen_call_ids: set[str] = set()
    pending_calls: set[str] = set()

    for idx, item in enumerate(items):
        if isinstance(item, FunctionCallItem):
            # Check for duplicate call IDs
            if item.call_id in seen_call_ids:
                violations.append(f"Duplicate call_id at index {idx}: {item.call_id}")
            seen_call_ids.add(item.call_id)
            pending_calls.add(item.call_id)

            # Validate arguments are valid JSON
            try:
                json_module.loads(item.arguments)
            except json_module.JSONDecodeError as exc:
                violations.append(
                    f"Invalid JSON arguments at index {idx} (call_id={item.call_id}): {exc}"
                )

        elif isinstance(item, FunctionCallOutputItem):
            # Check that output references a prior call
            if item.call_id not in pending_calls:
                violations.append(
                    f"Tool output at index {idx} references unknown call_id: {item.call_id}"
                )
            else:
                pending_calls.discard(item.call_id)

    if violations:
        raise ConversationInvariantError(
            f"Conversation invariants violated: {len(violations)} issues",
            violations=violations,
        )


def items_from_chat_completions(
    messages: Sequence[Mapping[str, Any]],
) -> list[ConversationItem]:
    """Convert Chat Completions messages to IR conversation items.

    Handles OpenAI Chat Completions format:
    - Messages with role and content
    - tool_calls embedded in assistant messages
    - Tool responses as role="tool" messages

    Args:
        messages: List of Chat Completions message dicts.

    Returns:
        List of ConversationItem instances.
    """
    items: list[ConversationItem] = []

    for msg in messages:
        role = msg.get("role", "")
        content = msg.get("content")
        tool_calls = msg.get("tool_calls")
        tool_call_id = msg.get("tool_call_id")

        if role == "tool":
            # Tool result message
            if tool_call_id:
                output = content if isinstance(content, str) else str(content or "")
                items.append(FunctionCallOutputItem(call_id=tool_call_id, output=output))
        elif role in ("user", "assistant", "system"):
            # Standard message - build content blocks
            content_blocks: list[ContentBlock] = []

            if isinstance(content, str) and content:
                if role == "assistant":
                    content_blocks.append(ContentBlock(type="output_text", content=content))
                else:
                    content_blocks.append(ContentBlock(type="input_text", content=content))
            elif isinstance(content, list):
                # Multi-modal content
                for part in content:
                    if isinstance(part, Mapping):
                        part_type = part.get("type", "")
                        if part_type == "text":
                            text_val = part.get("text", "")
                            if role == "assistant":
                                content_blocks.append(
                                    ContentBlock(type="output_text", content=text_val)
                                )
                            else:
                                content_blocks.append(
                                    ContentBlock(type="input_text", content=text_val)
                                )
                        elif part_type == "image_url":
                            img_content = part.get("image_url")
                            content_blocks.append(
                                ContentBlock(
                                    type="image",
                                    content=dict(img_content) if img_content else None,
                                )
                            )

            if content_blocks:
                items.append(
                    MessageItem(
                        role=role,  # type: ignore[arg-type]
                        content=tuple(content_blocks),
                    )
                )

            # Extract tool calls from assistant messages
            if tool_calls and role == "assistant":
                for tc in tool_calls:
                    if isinstance(tc, Mapping) and tc.get("type") == "function":
                        func = tc.get("function", {})
                        items.append(
                            FunctionCallItem(
                                call_id=tc.get("id", ""),
                                name=func.get("name", ""),
                                arguments=func.get("arguments", "{}"),
                            )
                        )

    return items


def items_from_responses(
    input_items: Sequence[Mapping[str, Any]],
) -> list[ConversationItem]:
    """Convert Responses API input items to IR conversation items.

    Handles OpenAI Responses format where items are first-class:
    - message items with role/content
    - function_call items
    - function_call_output items

    Args:
        input_items: List of Responses API input item dicts.

    Returns:
        List of ConversationItem instances.
    """
    items: list[ConversationItem] = []

    for item in input_items:
        item_type = item.get("type", "")

        if item_type == "message":
            role_raw = item.get("role", "user")
            role = role_raw if role_raw in ("user", "assistant", "system") else "user"
            content_list = item.get("content", [])

            content_blocks: list[ContentBlock] = []
            for part in content_list if isinstance(content_list, list) else []:
                if isinstance(part, Mapping):
                    pt = part.get("type", "")
                    if pt == "input_text":
                        content_blocks.append(
                            ContentBlock(type="input_text", content=part.get("text", ""))
                        )
                    elif pt == "output_text":
                        content_blocks.append(
                            ContentBlock(type="output_text", content=part.get("text", ""))
                        )
                    elif pt == "text":
                        # Map generic "text" to input_text
                        content_blocks.append(
                            ContentBlock(type="input_text", content=part.get("text", ""))
                        )
                    elif pt == "image":
                        content_blocks.append(ContentBlock(type="image", content=dict(part)))

            if content_blocks:
                items.append(
                    MessageItem(
                        role=role,  # type: ignore[arg-type]
                        content=tuple(content_blocks),
                        status=item.get("status"),
                    )
                )

        elif item_type == "function_call":
            items.append(
                FunctionCallItem(
                    call_id=item.get("call_id", ""),
                    name=item.get("name", ""),
                    arguments=item.get("arguments", "{}"),
                )
            )

        elif item_type == "function_call_output":
            items.append(
                FunctionCallOutputItem(
                    call_id=item.get("call_id", ""),
                    output=item.get("output", ""),
                )
            )

    return items


def items_to_chat_completions(
    items: Sequence[ConversationItem],
) -> list[dict[str, Any]]:
    """Convert IR conversation items to Chat Completions messages.

    Groups consecutive FunctionCallItem instances into a single assistant
    message with tool_calls, and converts FunctionCallOutputItem to tool
    role messages.

    Args:
        items: List of ConversationItem instances.

    Returns:
        List of Chat Completions message dicts.
    """
    import json as json_module

    messages: list[dict[str, Any]] = []
    pending_tool_calls: list[dict[str, Any]] = []
    last_assistant_content: str = ""

    def flush_tool_calls() -> None:
        nonlocal pending_tool_calls, last_assistant_content
        if pending_tool_calls:
            msg: dict[str, Any] = {"role": "assistant", "tool_calls": pending_tool_calls}
            if last_assistant_content:
                msg["content"] = last_assistant_content
            else:
                msg["content"] = None
            messages.append(msg)
            pending_tool_calls = []
            last_assistant_content = ""

    for item in items:
        if isinstance(item, MessageItem):
            flush_tool_calls()
            text_parts = [
                block.content
                for block in item.content
                if block.type in ("input_text", "output_text") and block.content
            ]
            content = "".join(str(p) for p in text_parts)
            messages.append({"role": item.role, "content": content})

        elif isinstance(item, FunctionCallItem):
            # Accumulate tool calls for batching in a single assistant message
            try:
                args_obj = json_module.loads(item.arguments)
            except json_module.JSONDecodeError:
                args_obj = item.arguments
            pending_tool_calls.append(
                {
                    "id": item.call_id,
                    "type": "function",
                    "function": {
                        "name": item.name,
                        "arguments": (
                            item.arguments
                            if isinstance(item.arguments, str)
                            else json_module.dumps(args_obj)
                        ),
                    },
                }
            )

        elif isinstance(item, FunctionCallOutputItem):
            flush_tool_calls()
            messages.append(
                {
                    "role": "tool",
                    "tool_call_id": item.call_id,
                    "content": item.output,
                }
            )

    flush_tool_calls()
    return messages


def items_to_responses(
    items: Sequence[ConversationItem],
) -> list[dict[str, Any]]:
    """Convert IR conversation items to Responses API input format.

    Each ConversationItem maps directly to a Responses API item:
    - MessageItem -> {type: "message", ...}
    - FunctionCallItem -> {type: "function_call", ...}
    - FunctionCallOutputItem -> {type: "function_call_output", ...}

    Args:
        items: List of ConversationItem instances.

    Returns:
        List of Responses API input item dicts.
    """
    output: list[dict[str, Any]] = []

    for item in items:
        if isinstance(item, MessageItem):
            content_list = []
            for block in item.content:
                if block.type in ("input_text", "output_text"):
                    content_list.append({"type": block.type, "text": block.content})
                elif block.type == "image":
                    image_content = block.content
                    if isinstance(image_content, dict):
                        content_list.append({"type": "image", **image_content})
                    else:
                        content_list.append({"type": "image"})

            msg_dict: dict[str, Any] = {
                "type": "message",
                "role": item.role,
                "content": content_list,
            }
            if item.status:
                msg_dict["status"] = item.status
            output.append(msg_dict)

        elif isinstance(item, FunctionCallItem):
            output.append(
                {
                    "type": "function_call",
                    "call_id": item.call_id,
                    "name": item.name,
                    "arguments": item.arguments,
                }
            )

        elif isinstance(item, FunctionCallOutputItem):
            output.append(
                {
                    "type": "function_call_output",
                    "call_id": item.call_id,
                    "output": item.output,
                }
            )

    return output


def items_to_anthropic(
    items: Sequence[ConversationItem],
) -> list[dict[str, Any]]:
    """Convert IR conversation items to Anthropic Messages format.

    Anthropic requires tool_result blocks to appear in user messages
    immediately after the assistant message with tool_use. This function
    ensures proper ordering and grouping.

    Args:
        items: List of ConversationItem instances.

    Returns:
        List of Anthropic message dicts.
    """
    import json as json_module

    messages: list[dict[str, Any]] = []
    pending_tool_uses: list[dict[str, Any]] = []
    pending_tool_results: list[dict[str, Any]] = []

    def flush_assistant_with_tools() -> None:
        nonlocal pending_tool_uses
        if pending_tool_uses:
            messages.append({"role": "assistant", "content": pending_tool_uses})
            pending_tool_uses = []

    def flush_tool_results() -> None:
        nonlocal pending_tool_results
        if pending_tool_results:
            messages.append({"role": "user", "content": pending_tool_results})
            pending_tool_results = []

    for item in items:
        if isinstance(item, MessageItem):
            flush_assistant_with_tools()
            flush_tool_results()

            content_blocks: list[dict[str, Any]] = []
            for block in item.content:
                if block.type in ("input_text", "output_text"):
                    content_blocks.append({"type": "text", "text": str(block.content)})
                elif block.type == "image":
                    if isinstance(block.content, Mapping):
                        content_blocks.append({"type": "image", **dict(block.content)})

            if content_blocks:
                messages.append({"role": item.role, "content": content_blocks})

        elif isinstance(item, FunctionCallItem):
            # Anthropic tool_use goes in assistant content
            try:
                input_obj = json_module.loads(item.arguments)
            except json_module.JSONDecodeError:
                input_obj = {"_raw": item.arguments}

            pending_tool_uses.append(
                {
                    "type": "tool_use",
                    "id": item.call_id,
                    "name": item.name,
                    "input": input_obj,
                }
            )

        elif isinstance(item, FunctionCallOutputItem):
            # Flush tool_uses first, then accumulate tool_results
            flush_assistant_with_tools()
            pending_tool_results.append(
                {
                    "type": "tool_result",
                    "tool_use_id": item.call_id,
                    "content": item.output,
                }
            )

    flush_assistant_with_tools()
    flush_tool_results()

    return messages


__all__ = [
    # Item-based IR types
    "ConversationInvariantError",
    "ConversationItem",
    "FunctionCallItem",
    "FunctionCallOutputItem",
    "MessageItem",
    # Item adapters
    "items_from_chat_completions",
    "items_from_responses",
    "items_to_anthropic",
    "items_to_chat_completions",
    "items_to_responses",
    "validate_conversation_invariants",
    # Legacy types (still used)
    "CanonicalTool",
    "CanonicalToolChoice",
    "CapabilityConflict",
    "ContentBlock",
    "EB1Request",
    "EB1Response",
    "Message",
    "NormalizationError",
    "ProviderTool",
    "TargetFormat",
    "Tool",
    "ToolChoiceAuto",
    "ToolChoiceForced",
    "ToolChoiceNone",
    "ToolChoiceRequired",
    "ToolPolicy",
    "apply_tool_policy",
    "is_ensemble_variant",
    "normalize_to_ir",
    "normalize_tool",
    "normalize_tool_choice",
    "normalize_tools",
    "render_from_ir",
    "tool_choice_to_dict",
    "tools_to_dict_list",
    "validate_streaming_tools",
]
