import logging
from datetime import datetime
from typing import Dict, List, Optional, Sequence, Union

from pydantic_core import PydanticUndefined, PydanticUndefinedType

from galtea.application.services.session_service import SessionService
from galtea.application.services.test_case_service import TestCaseService
from galtea.application.services.test_service import TestService
from galtea.application.services.trace_service import TraceService
from galtea.domain.models.evaluation import Evaluation
from galtea.domain.models.inference_result import (
    CostInfoProperties,
    InferenceResult,
    InferenceResultBase,
    InferenceResultUpdate,
    UsageInfoProperties,
)
from galtea.domain.models.session import Session
from galtea.domain.models.test_case import TestCase
from galtea.infrastructure.clients.http_client import Client
from galtea.utils.agent import Agent, AgentInput, AgentResponse, ConversationMessage
from galtea.utils.custom_score_metric import CustomScoreEvaluationMetric
from galtea.utils.metrics import MetricInput, process_metrics_with_score_calculation
from galtea.utils.string import build_query_params, is_valid_id
from galtea.utils.timestamp import normalize_timestamp
from galtea.utils.tracing import clear_context, set_context


class InferenceResultService:
    """
    Service for managing Inference Results.
    An Inference Result is a single turn in a conversation between a user and the AI, including the input and output.
    """

    def __init__(
        self,
        client: Client,
        session_service: SessionService,
        test_service: TestService,
        test_case_service: TestCaseService,
        trace_service: TraceService,
    ):
        """Initialize the InferenceResultService with the provided HTTP client.

        Args:
            client (Client): The HTTP client for making API requests.
            session_service (SessionService): The session service for session management.
            test_case_service (TestCaseService, optional): The test case service for retrieving test cases.
            trace_service (TraceService, optional): The trace service for saving traces.
        """
        self.__client: Client = client
        self.__session_service: SessionService = session_service
        self.__test_service: TestService = test_service
        self.__test_case_service: TestCaseService = test_case_service
        self.__trace_service: TraceService = trace_service
        self._logger: logging.Logger = logging.getLogger(__name__)

    def create(
        self,
        session_id: str,
        input: Optional[str] = None,
        output: Optional[str] = None,
        retrieval_context: Optional[str] = None,
        latency: Optional[float] = None,
        usage_info: Optional[UsageInfoProperties] = None,
        cost_info: Optional[CostInfoProperties] = None,
        conversation_simulator_version: Optional[str] = None,
    ) -> InferenceResult:
        """Create a new inference result log in a session.

        Args:
            session_id (str): The session ID to log the inference result to
            input (str, optional): The input text/prompt
            output (str, optional): The generated output/response
            retrieval_context (Optional[str]): Context retrieved for RAG systems
            latency (float, optional): Latency in milliseconds since the model was called until
                the response was received.
            usage_info (UsageInfoProperties, optional): Information about token usage during the
                model call.
                Possible keys include:
                - 'input_tokens': Number of input tokens sent to the model.
                - 'output_tokens': Number of output tokens generated by the model.
                - 'cache_read_input_tokens': Number of input tokens read from the cache.
            cost_info (CostInfoProperties, optional): Information about the cost per token during
                the model call.
                Possible keys include:
                - 'cost_per_input_token': Cost per input token sent to the model.
                - 'cost_per_output_token': Cost per output token generated by the model.
                - 'cost_per_cache_read_input_token': Cost per input token read from the cache.
            conversation_simulator_version (str, optional): The version of Galtea's conversation simulator
                used to generate the user message (input).
                This should only be provided if the input was generated using the conversation_simulator_service.

        Returns:
            InferenceResult: The created inference result log object
        """
        # Construct InferenceResultBase payload
        inference_result_base: InferenceResultBase = InferenceResultBase(
            session_id=session_id,
            actual_input=input,
            actual_output=output,
            retrieval_context=retrieval_context,
            latency=latency,
            **(
                cost_info.model_dump(exclude_none=True)
                if isinstance(cost_info, CostInfoProperties)
                else (cost_info or {})
            ),
            **(
                usage_info.model_dump(exclude_none=True)
                if isinstance(usage_info, UsageInfoProperties)
                else (usage_info or {})
            ),
            conversation_simulator_version=conversation_simulator_version,
        )

        # Validate the payload
        inference_result_base.model_validate(inference_result_base.model_dump())

        # Send the request - could be to /sessions/{session_id}/inference_results or /inference_results
        response = self.__client.post(
            "inferenceResults",
            json=inference_result_base.model_dump(by_alias=True, exclude_none=True),
        )

        return InferenceResult(**response.json())

    def create_batch(
        self,
        session_id: str,
        conversation_turns: List[Dict[str, str]],
        conversation_simulator_version: Optional[str] = None,
    ) -> List[InferenceResult]:
        """Create a batch of inference result in a session in a single http call.

        Args:
            session_id (str): The session ID to log the inference results to
            conversation_turns (list[dict[str, str]], optional): Historic of the past chat
                conversation turns from the user and the model. Each turn is a dictionary with
                "role" and "content" keys following the standard conversation format.
                For instance:
                - [
                    {"role": "user", "content": "what is the capital of France?"},
                    {"role": "assistant", "content": "Paris"},
                    {"role": "user", "content": "what is the population of that city?"},
                    {"role": "assistant", "content": "2M"}
                ]
            conversation_simulator_version (str, optional): The version of Galtea's conversation simulator
                used to generate the user messages (inputs).
                This should only be provided if using the conversation_simulator_service to generate user messages.

        Returns:
            List[InferenceResult]: List of created inference result objects
        """
        if not is_valid_id(session_id):
            raise ValueError("A valid session ID must be provided.")

        if not conversation_turns or not isinstance(conversation_turns, list):
            raise ValueError("conversation_turns must be a non-empty list of dictionaries.")

        response = self.__client.post(
            "inferenceResults/batch",
            json={
                "conversationTurns": conversation_turns,
                "sessionId": session_id,
                "conversationSimulatorVersion": conversation_simulator_version,
            },
        )
        inference_results = [InferenceResult(**result) for result in response.json()]
        return inference_results

    def list(
        self,
        session_id: Union[str, List[str]],
        from_created_at: Optional[Union[str, int]] = None,
        to_created_at: Optional[Union[str, int]] = None,
        sort_by_created_at: Optional[str] = None,
        offset: Optional[int] = None,
        limit: Optional[int] = None,
    ) -> List[InferenceResult]:
        """List inference result logs for a session.

        Args:
            session_id (str | list[str]): The session ID or list of session IDs to get inference results from.
            from_created_at (str | int, optional): Filter inference results created at or after this timestamp.
                Accepts ISO 8601 string (e.g., '2024-01-01T00:00:00Z') or Unix timestamp (seconds).
            to_created_at (str | int, optional): Filter inference results created at or before this timestamp.
                Accepts ISO 8601 string (e.g., '2024-12-31T23:59:59Z') or Unix timestamp (seconds).
            sort_by_created_at (str, optional): Sort by created at. Valid values are 'asc' and 'desc'.
            offset (int, optional): Offset for pagination.
                This refers to the number of items to skip before starting to collect the result set.
                The default value is 0.
            limit (int, optional): Limit for pagination.
                This refers to the maximum number of items to collect in the result set.

        Returns:
            List[InferenceResult]: List of inference result log objects
        """
        # 1. Validate IDs filter parameters
        session_ids = [session_id] if isinstance(session_id, str) else session_id
        if not session_ids or not all(is_valid_id(session_id) for session_id in session_ids):
            raise ValueError("A valid session ID must be provided.")

        # 2. Validate sort parameters
        if sort_by_created_at is not None and sort_by_created_at not in ["asc", "desc"]:
            raise ValueError("Sort by created at must be 'asc' or 'desc'.")

        # 3. Normalize date filter parameters
        from_created_at_normalized = normalize_timestamp(from_created_at)
        to_created_at_normalized = normalize_timestamp(to_created_at)

        query_params = build_query_params(
            sessionIds=session_ids,
            fromCreatedAt=from_created_at_normalized,
            toCreatedAt=to_created_at_normalized,
            offset=offset,
            limit=limit,
            sort=["createdAt", sort_by_created_at] if sort_by_created_at else None,
        )
        response = self.__client.get(f"inferenceResults?{query_params}")
        inference_results = [InferenceResult(**result) for result in response.json()]

        if not inference_results:
            for session_id in session_ids:
                session = self.__session_service.get(session_id)
                if not session:
                    raise ValueError(f"Session with ID {session_id} does not exist.")

        return inference_results

    def update(
        self,
        inference_result_id: str,
        input: Union[str, None, PydanticUndefinedType] = PydanticUndefined,
        output: Union[str, None, PydanticUndefinedType] = PydanticUndefined,
        retrieval_context: Union[str, None, PydanticUndefinedType] = PydanticUndefined,
        latency: Union[float, None, PydanticUndefinedType] = PydanticUndefined,
        input_tokens: Union[int, None, PydanticUndefinedType] = PydanticUndefined,
        output_tokens: Union[int, None, PydanticUndefinedType] = PydanticUndefined,
        cache_read_input_tokens: Union[int, None, PydanticUndefinedType] = PydanticUndefined,
        tokens: Union[int, None, PydanticUndefinedType] = PydanticUndefined,
        cost: Union[float, None, PydanticUndefinedType] = PydanticUndefined,
        cost_per_input_token: Union[float, None, PydanticUndefinedType] = PydanticUndefined,
        cost_per_output_token: Union[float, None, PydanticUndefinedType] = PydanticUndefined,
        cost_per_cache_read_input_token: Union[float, None, PydanticUndefinedType] = PydanticUndefined,
        conversation_simulator_version: Union[str, None, PydanticUndefinedType] = PydanticUndefined,
        created_at: Union[str, None, PydanticUndefinedType] = PydanticUndefined,
        deleted_at: Union[str, None, PydanticUndefinedType] = PydanticUndefined,
        index: Union[int, None, PydanticUndefinedType] = PydanticUndefined,
        session_id: Union[str, None, PydanticUndefinedType] = PydanticUndefined,
    ) -> InferenceResult:
        """
        Update an existing inference result with agent output and metadata.

        Args:
            inference_result_id (str): The ID of the inference result to update.
            output (str | None | Undefined): The generated output or response
                from the AI model for this turn.
            input (str | None | Undefined): The input text or prompt for
                the inference result.
            retrieval_context (str | None | Undefined): The context retrieved by
                a RAG system, if applicable.
            latency (float | None | Undefined): The time in milliseconds from
                request to response.
            input_tokens (int | None | Undefined): Number of input tokens sent
                to the model.
            output_tokens (int | None | Undefined): Number of output tokens
                generated by the model.
            cache_read_input_tokens (int | None | Undefined): Number of input
                tokens read from the cache.
            tokens (int | None | Undefined): Total tokens used in the model call.
            cost (float | None | Undefined): The total cost associated with the
                model call.
            cost_per_input_token (float | None | Undefined): Cost per input token
                sent to the model.
            cost_per_output_token (float | None | Undefined): Cost per output
                token generated by the model.
            cost_per_cache_read_input_token (float | None | Undefined): Cost per
                input token read from the cache.
            conversation_simulator_version (str | None | Undefined): The version
                of Galtea's conversation simulator used to generate the user
                message.
            created_at (str | None | Undefined): Timestamp when the inference
                result was created.
            deleted_at (str | None | Undefined): Timestamp when the inference
                result was deleted.
            index (int | None | Undefined): The position of this inference result
                within the session.
            session_id (str | None | Undefined): The session to which this
                inference result belongs.

        Returns:
            InferenceResult: The updated inference result object.

        Note:
            The creditsUsed field cannot be modified through this method as it's
            controlled by the API to prevent unauthorized credit manipulation.

            Pass PydanticUndefined (default) to exclude a field from the update.
            Pass None to explicitly set a field to null.
            Pass a value to update the field to that value.
        """
        if not is_valid_id(inference_result_id):
            raise ValueError("A valid inference result ID must be provided.")

        # Build a dictionary from the update args and filter out any keys that
        # were not actually provided by the caller (i.e. are PydanticUndefined).
        # This avoids passing PydanticUndefined values to the model constructor
        # which would cause the fields to be considered 'set' by pydantic and
        # therefore included in `model_dump(exclude_unset=True)` leading to
        # serialization problems because PydanticUndefined is not JSON serializable.
        update_data = {
            "actual_output": output,
            "actual_input": input,
            "retrieval_context": retrieval_context,
            "latency": latency,
            "input_tokens": input_tokens,
            "output_tokens": output_tokens,
            "cache_read_input_tokens": cache_read_input_tokens,
            "tokens": tokens,
            "cost": cost,
            "cost_per_input_token": cost_per_input_token,
            "cost_per_output_token": cost_per_output_token,
            "cost_per_cache_read_input_token": cost_per_cache_read_input_token,
            "conversation_simulator_version": conversation_simulator_version,
            "created_at": created_at,
            "deleted_at": deleted_at,
            "index": index,
            "session_id": session_id,
        }

        # Remove keys explicitly set to PydanticUndefined so they are not
        # considered 'set' by pydantic.
        filtered_update_data = {k: v for k, v in update_data.items() if v is not PydanticUndefined}

        inference_result_update = InferenceResultUpdate(**filtered_update_data)
        payload = inference_result_update.model_dump(by_alias=True, exclude_unset=True)
        response = self.__client.patch(
            f"inferenceResults/{inference_result_id}",
            json=payload,
        )
        return InferenceResult(**response.json())

    def get(self, inference_result_id: str) -> InferenceResult:
        """Retrieve an inference result log by its ID.

        Args:
            inference_result_id (str): The ID of the inference result log to retrieve

        Returns:
            InferenceResult: The retrieved inference result log object
        """
        if not is_valid_id(inference_result_id):
            raise ValueError("A valid inference result ID must be provided.")

        response = self.__client.get(f"inferenceResults/{inference_result_id}")
        return InferenceResult(**response.json())

    def delete(self, inference_result_id: str) -> None:
        """Delete an inference result log by its ID.

        Args:
            inference_result_id (str): The ID of the inference result log to delete
        """
        if not is_valid_id(inference_result_id):
            raise ValueError("A valid inference result ID must be provided.")

        self.__client.delete(f"inferenceResults/{inference_result_id}")

    def generate(
        self,
        agent: Agent,
        session: Session,
        user_input: Optional[str] = None,
        throw_if_empty_agent_response: bool = False,
    ) -> InferenceResult:
        """Run a single agent inference with automatic trace collection.

        This method simplifies the SDK usage by:
        1. Creating an inference result for the session
        2. Initializing trace collection
        3. Executing the agent with timing
        4. Updating the inference result with all metadata (including usage/cost from AgentResponse)
        5. Saving collected traces automatically
        6. Cleaning up trace context to prevent memory leaks

        Args:
            agent (Agent): The agent instance to execute. Must implement the Agent.call() method.
                The agent's call() method should return an AgentResponse with optional usage_info
                and cost_info for automatic tracking.
            session (Session): The session to associate with this inference. Create a session
                first using `client.sessions.create()`.
            user_input (str, optional): The user's input/prompt to send to the agent.
                If not given, will use the test case input if the session is a test-based session.
                Required for production/monitoring sessions.
            throw_if_empty_agent_response (bool, optional): If True, raises a ValueError when the agent returns an empty response.
                Defaults to False.

        Returns:
            InferenceResult: The created inference result with all traces saved.

        Raises:
            ValueError: If session is not provided or agent is not an Agent instance.
            Exception: If agent execution fails.

        Example:
            ```python
            import galtea
            from galtea import Agent, AgentInput, AgentResponse, trace, TraceType

            client = galtea.Galtea(api_key="your_api_key")

            # Define your agent with trace decorators
            class MyAgent(Agent):
                @trace(type=TraceType.RETRIEVER)
                def retrieve_context(self, query: str) -> list:
                    return [{"doc": "context"}]

                @trace("openai_call")
                def call_llm(self, prompt: str) -> dict:
                    # Simulate LLM call with usage/cost info
                    return {
                        "response": "LLM response",
                        "usage": {"input_tokens": 100, "output_tokens": 50},
                        "cost": {"cost_per_input_token": 0.0001, "cost_per_output_token": 0.0002}
                    }

                @trace(name = "main_entry_point")
                def call(self, agent_input: AgentInput) -> AgentResponse:
                    user_msg = agent_input.last_user_message_str()
                    context = self.retrieve_context(user_msg)
                    llm_result = self.call_llm(f"{context}\n{user_msg}")

                    return AgentResponse(
                        content=llm_result["response"],
                        usage_info=llm_result["usage"],
                        cost_info=llm_result["cost"]
                    )

            agent = MyAgent()

            # Create a session first
            session = client.sessions.create(version_id="version_123")

            # Run inference with the session
            result = client.inference_results.generate(
                agent=agent,
                session=session,
            )

            print(f"Inference ID: {result.id}")
            print(f"Session ID: {result.session_id}")
            print(f"Input: {result.actual_input}")
            print(f"Output: {result.actual_output}")
            ```
        """
        # Validate inputs
        if not isinstance(agent, Agent):
            raise ValueError("agent must be an instance of Agent class")

        if not user_input:
            if session.is_monitoring():
                raise ValueError("Monitoring (production) sessions need a 'user_input' parameter")
            else:
                test_case = self.__test_case_service.get(session.test_case_id)  # type: ignore # If is_monitoring is False, test_case_id is not None
                test = self.__test_service.get(test_case.test_id)
                if test.type == "SCENARIOS":
                    raise ValueError(
                        "sessions from SCENARIOS tests are not supported in this method. "
                        "Please use 'galtea.simulator.simulate()' to run this session."
                    )
                user_input = test_case.input

        inference_result = self.create(
            session_id=session.id,
            input=user_input or "",
            output=None,
        )

        return self._execute_agent(
            agent,
            session.id,
            [ConversationMessage(role="user", content=user_input or "")],
            inference_result.id,
            throw_if_empty_agent_response=throw_if_empty_agent_response,
        )

    def _execute_agent(
        self,
        agent: Agent,
        session_id: str,
        messages: List[ConversationMessage],
        inference_result_id: Optional[str] = None,
        include_metadata: bool = False,
        throw_if_empty_agent_response: bool = False,
    ) -> InferenceResult:
        if not inference_result_id:
            inference_result = self.create(
                session_id=session_id,
            )
            inference_result_id = inference_result.id

        # Set OTel context for trace correlation
        context_token = set_context(
            inference_result_id=inference_result_id,
        )

        try:
            agent_input = AgentInput(
                messages=messages,
                session_id=session_id,
            )

            time_before_call = datetime.now()
            agent_response: AgentResponse = agent.call(agent_input)
            time_after_call = datetime.now()

            if isinstance(agent_response, Dict):
                try:
                    agent_response = AgentResponse(**agent_response)
                except Exception as e:
                    self._logger.error(
                        f"Failed to convert dict to AgentResponse: {e}. "
                        f"Dict keys: {agent_response.keys() if isinstance(agent_response, dict) else 'N/A'}"
                    )
                    raise ValueError(f"Failed to convert agent response dict to AgentResponse: {e}")
            elif not isinstance(agent_response, AgentResponse):
                self._logger.error(
                    f"Agent returned unexpected type: {type(agent_response).__name__}, an AgentResponse object was expected"
                )
                raise ValueError(
                    f"Agent must return an AgentResponse class or dict object, got {type(agent_response).__name__}.\n"
                    "Make sure your agent returns a proper AgentResponse or a dict with the required fields."
                )

            if not agent_response.content:
                self._logger.warning("Agent returned an empty response")
                if throw_if_empty_agent_response:
                    raise ValueError("Agent returned an empty response")

            # Add the agent's response directly to the conversation history (modifies variable passed)
            messages.append(
                ConversationMessage(
                    role="assistant",
                    content=agent_response.content,
                    metadata=agent_response.metadata if include_metadata else None,
                )
            )

            # Extract usage and cost info from AgentResponse
            usage = agent_response.usage_info
            cost = agent_response.cost_info

            # Update or create inference result with agent output, latency, and usage/cost info
            latency_ms = (time_after_call - time_before_call).total_seconds() * 1000
            inference_result = self.update(
                inference_result_id=inference_result_id,
                input=agent_input.last_user_message_str(),
                output=agent_response.content,
                retrieval_context=agent_response.retrieval_context,
                latency=latency_ms,
                input_tokens=usage.input_tokens if usage else None,
                output_tokens=usage.output_tokens if usage else None,
                cache_read_input_tokens=usage.cache_read_input_tokens if usage else None,
                tokens=usage.tokens if usage else None,
                cost_per_input_token=cost.cost_per_input_token if cost else None,
                cost_per_output_token=cost.cost_per_output_token if cost else None,
                cost_per_cache_read_input_token=cost.cost_per_cache_read_input_token if cost else None,
                cost=cost.cost if cost else None,
            )

            # Traces are automatically exported via OTel processors
            return inference_result

        finally:
            clear_context(context_token)

    def create_and_evaluate(
        self,
        session_id: str,
        output: str,
        metrics: Optional[Sequence[Union[str, CustomScoreEvaluationMetric, MetricInput]]] = None,
        input: Optional[str] = None,
        retrieval_context: Optional[str] = None,
        latency: Optional[float] = None,
        usage_info: Optional[UsageInfoProperties] = None,
        cost_info: Optional[CostInfoProperties] = None,
        conversation_simulator_version: Optional[str] = None,
    ) -> tuple[InferenceResult, List[Evaluation]]:
        """
        Creates an inference result and immediately evaluates it against the specified metrics.

        This method combines the creation of an inference result with its evaluation in a single
        convenient call. It's the recommended approach for single-turn evaluations, replacing
        the deprecated ``galtea.evaluations.create_single_turn()`` method.

        Args:
            session_id (str): The session ID to log the inference result to.
            output (str): The generated output/response from the AI model.
            metrics (Optional[Sequence[Union[str, CustomScoreEvaluationMetric, MetricInput]]]):
                List of metrics to evaluate against. Supports multiple formats:
                - Strings: Metric names (e.g., ``["accuracy", "relevance"]``)
                - CustomScoreEvaluationMetric: Objects with dynamic score calculation.
                  Must be initialized with either 'name' or 'id' parameter.
                - MetricInput dicts: Format with optional id, name, and score.
                  The score field can be either:
                  * float: Pre-calculated score (requires 'id' or 'name' in the dict).
                  * CustomScoreEvaluationMetric: Dynamic score calculation.
            input (str, optional): The input text/prompt. If not provided, will be inferred
                from the test case linked to the session.
            retrieval_context (Optional[str]): Context retrieved by a RAG system, if applicable.
            latency (float, optional): Latency in milliseconds from model invocation to response.
            usage_info (UsageInfoProperties, optional): Token usage information from the model call.
                Supported keys: ``input_tokens``, ``output_tokens``, ``cache_read_input_tokens``.
            cost_info (CostInfoProperties, optional): Cost breakdown for the model call.
                Supported keys: ``cost_per_input_token``, ``cost_per_output_token``,
                ``cost_per_cache_read_input_token``.
            conversation_simulator_version (str, optional): Version of Galtea's conversation
                simulator used to generate the input.

        Returns:
            tuple[InferenceResult, List[Evaluation]]: A tuple containing:
                - The created InferenceResult object
                - A list of created Evaluation objects, one per metric

        Raises:
            ValueError: If session_id is invalid or no metrics are provided.
            TypeError: If an invalid metric type is provided.

        Example usage:
            >>> # Simple evaluation with metric names
            >>> inference_result, evaluations = galtea.inference_results.create_and_evaluate(
            ...     session_id="session_123",
            ...     output="The capital of France is Paris.",
            ...     metrics=["accuracy", "relevance"],
            ... )

        Example with custom scoring:
            >>> from galtea.utils.custom_score_metric import CustomScoreEvaluationMetric
            >>>
            >>> class MyMetric(CustomScoreEvaluationMetric):
            ...     def measure(self, input, actual_output, expected_output, retrieval_context, context):
            ...         return 0.95  # Your custom scoring logic
            >>>
            >>> custom_metric = MyMetric(name="custom_accuracy")
            >>> inference_result, evaluations = galtea.inference_results.create_and_evaluate(
            ...     session_id="session_123",
            ...     output="Model response...",
            ...     metrics=[
            ...         {"name": "accuracy"},
            ...         {"score": custom_metric},
            ...     ],
            ... )
        """
        if not is_valid_id(session_id):
            raise ValueError("Session ID provided is not valid.")

        metrics = metrics or []
        if not metrics:
            raise ValueError("At least one metric must be provided for evaluation.")

        # Step 1: Fetch session and infer input if not provided
        session: Session = self.__session_service.get(session_id)
        test_case: Optional[TestCase] = None

        if not input:
            if session.is_monitoring():
                raise ValueError("Monitoring (production) sessions need an 'input' parameter")
            else:
                test_case = self.__test_case_service.get(session.test_case_id)  # type: ignore # If is_monitoring is False, test_case_id is not None
                test = self.__test_service.get(test_case.test_id)
                if test.type == "SCENARIOS":
                    raise ValueError(
                        "sessions from SCENARIOS tests are not supported in this method. "
                        "Please use 'galtea.simulator.simulate()' to run this session."
                    )
                input = test_case.input

        # Step 2: Create the inference result
        inference_result = self.create(
            session_id=session_id,
            input=input,
            output=output,
            retrieval_context=retrieval_context,
            latency=latency,
            usage_info=usage_info,
            cost_info=cost_info,
            conversation_simulator_version=conversation_simulator_version,
        )

        # Step 3: Get test case for expected output and context (needed for score calculation)
        if session.test_case_id is not None and test_case is None:
            test_case = self.__test_case_service.get(session.test_case_id)

        # Step 4: Process metrics with score calculation
        api_metrics = process_metrics_with_score_calculation(
            metrics=metrics,
            input_data=input or inference_result.actual_input,
            actual_output=output,
            expected_output=test_case.expected_output if test_case else None,
            retrieval_context=retrieval_context,
            context=test_case.context if test_case else None,
        )

        # Step 5: Call evaluation API
        request_body = {
            "metrics": api_metrics,
            "inferenceResultId": inference_result.id,
        }

        response = self.__client.post("evaluations/fromInferenceResult", json=request_body)
        evaluations = [Evaluation(**evaluation) for evaluation in response.json()]

        return inference_result, evaluations
