from typing import Dict, List, Optional, Union

from galtea.application.services.metric_service import MetricService
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.version_service import VersionService
from galtea.domain.exceptions.entity_not_found_exception import EntityNotFoundException
from galtea.domain.models.evaluation import Evaluation
from galtea.domain.models.inference_result import CostInfoProperties, UsageInfoProperties
from galtea.domain.models.test_case import TestCase
from galtea.infrastructure.clients.http_client import Client
from galtea.utils.custom_score_metric import CustomScoreEvaluationMetric
from galtea.utils.string import build_query_params, is_valid_id


class EvaluationService:
    """
    Service for managing Evaluations.
    An Evaluation is the result of an evaluation against a specific metric and its criteria.
    """

    def __init__(
        self,
        client: Client,
        metric_service: MetricService,
        session_service: SessionService,
        test_case_service: TestCaseService,
        test_service: TestService,
        version_service: VersionService,
    ):
        self._client = client
        self.metric_service = metric_service
        self.session_service = session_service
        self.test_case_service = test_case_service
        self.test_service = test_service
        self.version_service = version_service

    def create_single_turn(
        self,
        version_id: str,
        actual_output: str,
        metrics: List[Union[str, CustomScoreEvaluationMetric]],
        test_case_id: Optional[str] = None,
        input: Optional[str] = None,
        is_production: Optional[bool] = None,
        retrieval_context: Optional[str] = None,
        latency: Optional[float] = None,
        usage_info: Optional[Dict[str, float]] = None,
        cost_info: Optional[Dict[str, float]] = None,
        conversation_simulator_version: Optional[str] = None,
    ) -> Optional[List[Evaluation]]:
        """
        Creates evaluation for a single-turn evaluation, assessing product performance based on
        specified metrics. For each metric (standard or custom) provided, a new evaluation is created.

        Args:
            version_id (str): ID of the version to associate with the evaluations.
            actual_output (str): The actual output generated by the system under evaluation.
            metrics (List[str | CustomScoreEvaluationMetric]):
                List of metrics as strings, and/or custom metric objects.
                - Standard metrics should be provided as strings.
                - Metrics with a custom score should inherit from CustomScoreEvaluationMetric.
            test_case_id (Optional[str]): ID of the test case used for the evaluation, linking to
                predefined inputs, expected outputs, and context. Mandatory when not tracking production data.
            input (Optional[str]): The input text/prompt for the evaluation. Only applicable
                when `test_case_id` is not provided, for tracking production data.
            is_production (Optional[bool]): If True, the evaluation is considered PRODUCTION data and
                no test_case_id parameter is needed. Default is False.
            retrieval_context (Optional[str]): Context retrieved by a RAG system, if applicable.
            latency (Optional[float]): Latency in milliseconds since the model was called until
                the response was received.
            usage_info (Optional[Dict[str, float]]): 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 (Optional[Dict[str, float]]): 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 (Optional[str]): 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:
            Optional[List[Evaluation]]: List of created evaluations, or None if an error occurs.

        Example usage:
            >>> galtea.evaluations.create_single_turn(
            ...     test_case_id="test_case.id",
            ...     version_id="version.id",
            ...     actual_output="actual output...",
            ...     metrics=["anyStandardMetric", custom_relevance, custom_accuracy],
            ...     conversation_simulator_version="1.2.3",
            ... )
        """
        if metrics and not isinstance(metrics, list):
            raise TypeError("'metrics' parameter must be a list.")

        metric_names: List[str] = []
        metric_scores: List[Union[float, None]] = []

        test_case: Union[TestCase, None] = None

        for metric_obj in metrics:
            metric_name = metric_obj if isinstance(metric_obj, str) else metric_obj.name
            metric_names.append(metric_name)
            if isinstance(metric_obj, str):
                metric_scores.append(None)
            elif isinstance(metric_obj, CustomScoreEvaluationMetric):
                self.metric_service.get_by_name(metric_name)

                if test_case is None and test_case_id is not None:
                    test_case = self.test_case_service.get(test_case_id)

                try:
                    metric_scores.append(
                        metric_obj(
                            input=input,
                            actual_output=actual_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,
                        )
                    )
                except Exception as e:
                    raise ValueError(f"Failed to generate score for custom metric '{metric_obj.name}': {e}") from e
            else:
                raise TypeError(f"Invalid metric: {type(metric_name)}.")

        if usage_info is not None:
            for key, _ in usage_info.items():
                if key not in UsageInfoProperties.model_fields:
                    raise KeyError(
                        f"Invalid key: {key}. Must be one of: {', '.join(UsageInfoProperties.model_fields.keys())}"
                    )

        if cost_info is not None:
            for key, _ in cost_info.items():
                if key not in CostInfoProperties.model_fields:
                    raise KeyError(
                        f"Invalid key: {key}. Must be one of: {', '.join(CostInfoProperties.model_fields.keys())}"
                    )

        request_body = {
            "metricNames": metric_names,
            "versionId": version_id,
            "testCaseId": test_case_id,
            "actualOutput": actual_output,
            "input": input,
            "scores": metric_scores,
            "retrievalContext": retrieval_context,
            "latency": latency,
            "usageInfo": usage_info,
            "costInfo": cost_info,
            "isProduction": is_production,
            "conversationSimulatorVersion": conversation_simulator_version,
        }

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

    def create(
        self,
        metrics: List[str],
        session_id: str,
    ) -> List[Evaluation]:
        """
        Creates evaluation for a given session, assessing product performance based on specified standard metrics.
        For each metric provided, a new evaluation is created.

        Args:
            metrics (List[str]):
                List of standard metrics as strings to evaluate.
                Metrics with a custom score are not supported for session-based evaluation.
            session_id (str): ID of the session to associate with the evaluations.

        Returns:
            List[Evaluation]: List of created evaluations.
        """
        if not metrics:
            raise ValueError("The 'metrics' parameter must be a non-empty list.")
        if not isinstance(metrics, list):
            raise TypeError("'metrics' parameter must be a list.")
        if not is_valid_id(session_id):
            raise ValueError("Session ID provided is not valid.")

        metric_names: List[str] = []
        for metric_obj in metrics:
            if isinstance(metric_obj, CustomScoreEvaluationMetric):
                raise ValueError(
                    "Custom-score metrics are not supported for session-based evaluation. "
                    "Use 'create_single_turn' for custom-scored metrics."
                )

            metric_name = metric_obj if isinstance(metric_obj, str) else metric_obj.name

            metric_names.append(metric_name)

        request_body = {
            "metricNames": metric_names,
            "sessionId": session_id,
        }

        # Send the request with the complete body
        response = self._client.post("evaluations/fromSession", json=request_body)
        evaluations = [Evaluation(**evaluation) for evaluation in response.json()]
        return evaluations

    def get(self, evaluation_id: str):
        """
        Retrieve an evaluation by its ID.

        Args:
            evaluation_id (str): ID of the evaluation to retrieve.

        Returns:
            Evaluation: The retrieved evaluation object.
        """
        if not is_valid_id(evaluation_id):
            raise ValueError("Evaluation ID provided is not valid.")

        response = self._client.get(f"evaluations/{evaluation_id}")
        return Evaluation(**response.json())

    def list(
        self,
        session_id: Optional[Union[str, list[str]]] = None,
        version_id: Optional[str] = None,
        test_case_id: Optional[Union[str, list[str]]] = None,
        test_id: Optional[Union[str, list[str]]] = None,
        metric_id: Optional[Union[str, list[str]]] = None,
        sort_by_created_at: Optional[str] = None,
        sort_by_score: Optional[str] = None,
        offset: Optional[int] = None,
        limit: Optional[int] = None,
    ):
        """
        Get a list of evaluations for a given session.

        Args:
            session_id (str | list[str], optional): ID or IDs of the sessions associated with the
                evaluations to retrieve.
            version_id (str, optional): ID of the version associated with the evaluations to retrieve.
            test_case_id (str | list[str], optional): ID or IDs of the test cases associated with the
                evaluations to retrieve.
            test_id (str | list[str], optional): ID or IDs of the tests associated with the
                evaluations to retrieve.
            metric_id (str | list[str], optional): ID or IDs of the metrics associated with the
                evaluations to retrieve.
            sort_by_created_at (str, optional): Sort by created at. Valid values are 'asc' and 'desc'.
            sort_by_score (str, optional): Sort by score. 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[Evaluation]: List of evaluations.
        """
        # 1. Validate IDs filter parameters
        if version_id is not None and not is_valid_id(version_id):
            raise ValueError("version_id parameter has to be a string with a valid ID.")

        # 1.1 Normalize single string inputs to lists
        session_ids = [session_id] if isinstance(session_id, str) else session_id
        version_ids = [version_id] if isinstance(version_id, str) else version_id
        test_case_ids = [test_case_id] if isinstance(test_case_id, str) else test_case_id
        test_ids = [test_id] if isinstance(test_id, str) else test_id
        metric_ids = [metric_id] if isinstance(metric_id, str) else metric_id
        if not (session_ids or version_ids or test_case_ids or test_ids or metric_ids):
            raise ValueError("At least one ID filter parameter must be provided.")

        # 1.2 Validate IDs
        if session_ids is not None and not all(is_valid_id(session_id) for session_id in session_ids):
            raise ValueError("Session ID provided is not valid.")
        if version_ids is not None and not all(is_valid_id(version_id) for version_id in version_ids):
            raise ValueError("Version ID provided is not valid.")
        if test_case_ids is not None and not all(is_valid_id(test_case_id) for test_case_id in test_case_ids):
            raise ValueError("Test case ID provided is not valid.")
        if test_ids is not None and not all(is_valid_id(test_id) for test_id in test_ids):
            raise ValueError("Test ID provided is not valid.")
        if metric_ids is not None and not all(is_valid_id(metric_id) for metric_id in metric_ids):
            raise ValueError("Metric ID provided is not valid.")

        # 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'.")
        if sort_by_score is not None and sort_by_score not in ["asc", "desc"]:
            raise ValueError("Sort by score must be 'asc' or 'desc'.")

        query_params = build_query_params(
            sessionIds=session_ids,
            versionIds=version_ids,
            testCaseIds=test_case_ids,
            testIds=test_ids,
            metricIds=metric_ids,
            offset=offset,
            limit=limit,
            sort=[
                *(["createdAt", sort_by_created_at] if sort_by_created_at else []),
                *(["score", sort_by_score] if sort_by_score else []),
            ],
        )
        response = self._client.get(f"evaluations?{query_params}")
        evaluations = [Evaluation(**evaluation) for evaluation in response.json()]

        if not evaluations:
            if session_ids:
                try:
                    for session_id in session_ids:
                        self.session_service.get(session_id)
                except Exception:
                    raise EntityNotFoundException(f"Session with ID {session_id} does not exist.")
            if version_ids:
                try:
                    for version_id in version_ids:
                        self.version_service.get(version_id)
                except Exception:
                    raise EntityNotFoundException(f"Version with ID {version_id} does not exist.")
            if test_case_ids:
                try:
                    for test_case_id in test_case_ids:
                        self.test_case_service.get(test_case_id)
                except Exception:
                    raise EntityNotFoundException(f"Test case with ID {test_case_id} does not exist.")
            if test_ids:
                try:
                    for test_id in test_ids:
                        self.test_service.get(test_id)
                except Exception:
                    raise EntityNotFoundException(f"Test with ID {test_id} does not exist.")
            if metric_ids:
                try:
                    for metric_id in metric_ids:
                        self.metric_service.get(metric_id)
                except Exception:
                    raise EntityNotFoundException(f"Metric with ID {metric_id} does not exist.")

        return evaluations

    def delete(self, evaluation_id: str):
        """
        Delete an evaluation by its ID.

        Args:
            evaluation_id (str): ID of the evaluation to delete.

        Returns:
            None.
        """
        if not is_valid_id(evaluation_id):
            raise ValueError("Evaluation ID provided is not valid.")

        self._client.delete(f"evaluations/{evaluation_id}")
