import warnings
from typing import Dict, List, Optional, Sequence, Union

from galtea.application.services.inference_result_service import InferenceResultService
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, EvaluationStatus
from galtea.domain.models.inference_result import CostInfoProperties, InferenceResult, 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.custom_score_metric import CustomScoreEvaluationMetric
from galtea.utils.metrics import MetricInput, process_metrics_with_score_calculation
from galtea.utils.sort_order import SortOrder, normalize_sort_order
from galtea.utils.string import build_query_params, is_valid_id
from galtea.utils.timestamp import normalize_timestamp


def _warn_if_legacy(evaluations: List["Evaluation"]) -> None:
    for evaluation in evaluations:
        if evaluation.metric_legacy_at is not None:
            warnings.warn(
                f"Metric '{evaluation.metric_id}' is deprecated (since {evaluation.metric_legacy_at}) "
                f"and will be made non-evaluable in a future release. "
                f"Please migrate to a supported replacement metric.",
                DeprecationWarning,
                stacklevel=3,
            )


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,
        inference_result_service: InferenceResultService,
    ):
        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
        self.__inference_result_service = inference_result_service

    def create_single_turn(
        self,
        version_id: str,
        actual_output: str,
        metrics: Optional[Sequence[Union[str, CustomScoreEvaluationMetric, MetricInput]]] = None,
        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]]:
        """
        .. deprecated::
            This method is deprecated and will be removed in a future version.
            Use :meth:`galtea.inference_results.create_and_evaluate()` instead for single-turn
            evaluations, or use :meth:`galtea.evaluations.create(inference_result_id=...)` for
            evaluating an existing inference result.

        Creates evaluation for a single-turn interaction, assessing system performance based on
        specified metrics. A separate evaluation is generated for each metric provided.

        This method supports both current and deprecated input formats for metrics, ensuring
        backward compatibility with existing integrations while encouraging migration to the
        recommended MetricInput format.

        Args:
            version_id (str): The unique identifier of the version being evaluated.
            actual_output (str): The actual output generated by the system under evaluation.
            metrics (Optional[List[Union[str, CustomScoreEvaluationMetric, MetricInput]]]):
                List of metrics to evaluate against. Supports multiple formats for flexibility:
                - Strings: Metric names (deprecated, maintained for backward compatibility)
                - CustomScoreEvaluationMetric: Objects with dynamic score calculation (deprecated,
                  maintained for backward compatibility). Must be initialized with either 'name'
                  or 'id' parameter.
                - MetricInput dicts: Recommended format with optional id, name, and score.
                  The score field can be either:
                  * float: Pre-calculated score. Requires 'id' or 'name' in the dict.
                    Example: {'name': 'accuracy', 'score': 0.95}
                  * CustomScoreEvaluationMetric: Object for dynamic score calculation.
                    The CustomScoreEvaluationMetric must be initialized with 'name' or 'id'.
                    Do NOT provide 'id' or 'name' in the MetricInput dict itself.
                    Example: {'score': CustomScoreEvaluationMetric(name='custom_metric')}
            test_case_id (Optional[str]): The ID of the test case used for evaluation. Links to
                predefined inputs, expected outputs, and context. Required for test-based evaluation
                (when is_production is False or omitted).
            input (Optional[str]): The input text or prompt for the evaluation. Only used when
                is_production is True to track production data without a predefined test case.
            is_production (Optional[bool]): Indicates whether this evaluation is from production data.
                Default is False. When True, test_case_id is not required but input becomes required.
            retrieval_context (Optional[str]): Context retrieved by a RAG system, if applicable.
                Useful for evaluations involving retrieval-augmented generation.
            latency (Optional[float]): The latency in milliseconds from model invocation to response
                receipt.
            usage_info (Optional[Dict[str, float]]): Token usage information from the model call.
                Supported 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 cached input tokens used
            cost_info (Optional[Dict[str, float]]): Cost breakdown for the model call.
                Supported keys include:
                - 'cost_per_input_token': Cost per input token
                - 'cost_per_output_token': Cost per output token
                - 'cost_per_cache_read_input_token': Cost per cached input token
            conversation_simulator_version (Optional[str]): Version of Galtea's conversation
                simulator used to generate the input. Include this when the input was produced
                by the conversation_simulator_service.

        Returns:
            Optional[List[Evaluation]]: A list of created Evaluation objects, one per metric.
                Returns None if an error occurs during evaluation creation.

        Raises:
            ValueError: If required parameters are missing or invalid.
            KeyError: If usage_info or cost_info contain unsupported keys.

        Example usage with deprecated format (still supported):
            >>> evaluations = galtea.evaluations.create_single_turn(
            ...     test_case_id="tc_123",
            ...     version_id="v_456",
            ...     actual_output="Model response...",
            ...     metrics=["accuracy", "precision"],  # Deprecated but still works
            ... )

        Example usage with recommended format (pre-calculated score):
            >>> evaluations = galtea.evaluations.create_single_turn(
            ...     test_case_id="tc_123",
            ...     version_id="v_456",
            ...     actual_output="Model response...",
            ...     metrics=[
            ...         {"name": "accuracy"},
            ...         {"id": "metric_xyz", "score": 0.95},  # Pre-calculated score
            ...     ],
            ... )

        Example usage with CustomScoreEvaluationMetric in MetricInput:
            >>> from galtea.utils.custom_score_metric import CustomScoreEvaluationMetric
            >>>
            >>> class MyCustomMetric(CustomScoreEvaluationMetric):
            ...     def measure(self, input, actual_output, expected_output, retrieval_context, context):
            ...         return 0.85  # Your custom logic here
            >>>
            >>> # Initialize with name or id - NOT both
            >>> custom_metric = MyCustomMetric(name="custom_metric")
            >>> # OR: custom_metric = MyCustomMetric(id="metric_xyz")
            >>>
            >>> evaluations = galtea.evaluations.create_single_turn(
            ...     test_case_id="tc_123",
            ...     version_id="v_456",
            ...     actual_output="Model response...",
            ...     metrics=[
            ...         {"name": "accuracy"},
            ...         # Note: No 'id' or 'name' in dict when using CustomScoreEvaluationMetric
            ...         {"score": custom_metric},  # Dynamic score calculation
            ...     ],
            ... )
        """

        metrics = metrics or []

        import warnings

        warnings.warn(
            "create_single_turn() is deprecated and will be removed in a future version. "
            "Use galtea.inference_results.create_and_evaluate() instead for single-turn evaluations, "
            "or use galtea.evaluations.create(inference_result_id=...) to evaluate an existing inference result.",
            DeprecationWarning,
            stacklevel=2,
        )

        # Fetch test case if needed for score calculation
        test_case: Union[TestCase, None] = None
        if test_case_id is not None:
            test_case = self.__test_case_service.get(test_case_id)

        # Process metrics and calculate scores for CustomScoreEvaluationMetric
        api_metrics = process_metrics_with_score_calculation(
            metrics=metrics,
            input_data=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,
        )

        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 = {
            "metrics": api_metrics,
            "versionId": version_id,
            "testCaseId": test_case_id,
            "actualOutput": actual_output,
            "input": input,
            "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,
        session_id: Optional[str] = None,
        inference_result_id: Optional[str] = None,
        metrics: Optional[Sequence[Union[str, CustomScoreEvaluationMetric, MetricInput]]] = None,
        specification_ids: Optional[List[str]] = None,
    ) -> List[Evaluation]:
        """
        Creates evaluations for an existing session or a single inference result,
        assessing system performance using specified metrics.
        A separate evaluation is generated for each metric.

        You must provide either `session_id` OR `inference_result_id`, but not both.
        - Use `session_id` to evaluate an entire conversation (all turns in the session).
        - Use `inference_result_id` to evaluate a single turn. This is particularly useful
          for production monitoring where you want to evaluate turn-by-turn performance.

        This method supports both current and deprecated input formats for metrics, ensuring
        backward compatibility with existing integrations while encouraging migration to the
        recommended MetricInput format.

        Args:
            session_id (Optional[str]): The unique identifier of the session to evaluate.
                Cannot be provided together with inference_result_id.
            inference_result_id (Optional[str]): The unique identifier of the inference result
                (single turn) to evaluate. Cannot be provided together with session_id.
                When evaluating a single turn, the evaluation is still linked to the session
                for consistency and easier querying.
            specification_ids (Optional[List[str]]): List of specification IDs to evaluate against.
                When provided, the evaluation uses the metrics linked to the given specifications.
                Can be combined with `metrics`; the API merges and deduplicates by metric ID.
                If neither `metrics` nor `specification_ids` is provided, the API falls back to
                metrics from all specifications linked to the product.
            metrics (Optional[List[Union[str, CustomScoreEvaluationMetric, MetricInput]]]):
                List of metrics to evaluate against. Supports multiple formats for flexibility:
                - Strings: Metric names (deprecated, maintained for backward compatibility)
                - CustomScoreEvaluationMetric: Objects with dynamic score calculation (deprecated,
                  maintained for backward compatibility). Must be initialized with either 'name'
                  or 'id' parameter.
                - MetricInput dicts: Recommended format with optional id, name, and score.
                  The score field can be either:
                  * float: Pre-calculated score. Requires 'id' or 'name' in the dict.
                    Example: {'name': 'accuracy', 'score': 0.95}
                  * CustomScoreEvaluationMetric: Object for dynamic score calculation.
                    The CustomScoreEvaluationMetric must be initialized with 'name' or 'id'.
                    Do NOT provide 'id' or 'name' in the MetricInput dict itself.
                    Example: {'score': CustomScoreEvaluationMetric(name='custom_metric')}

        Returns:
            List[Evaluation]: A list of created Evaluation objects, one per metric.

        Raises:
            ValueError: If neither or both session_id and inference_result_id are provided,
                or if validation fails.

        Example usage with session_id (evaluate full conversation):
            >>> evaluations = galtea.evaluations.create(
            ...     session_id="session_123",
            ...     metrics=[{"name": "accuracy"}],
            ... )

        Example usage with inference_result_id (evaluate single turn):
            >>> # Create an inference result first
            >>> inference_result = galtea.inference_results.create(
            ...     session_id=session.id,
            ...     input="User question",
            ...     output="AI response",
            ... )
            >>>
            >>> # Evaluate just this specific turn
            >>> evaluations = galtea.evaluations.create(
            ...     inference_result_id=inference_result.id,
            ...     metrics=[{"name": "accuracy"}],
            ... )

        Example usage with CustomScoreEvaluationMetric in MetricInput:
            >>> from galtea.utils.custom_score_metric import CustomScoreEvaluationMetric
            >>>
            >>> class MyCustomMetric(CustomScoreEvaluationMetric):
            ...     def measure(self, input, actual_output, expected_output, retrieval_context, context):
            ...         return 0.85  # Your custom logic here
            >>>
            >>> # Initialize with name or id - NOT both
            >>> custom_metric = MyCustomMetric(name="custom_metric")
            >>> # OR: custom_metric = MyCustomMetric(id="metric_xyz")
            >>>
            >>> evaluations = galtea.evaluations.create(
            ...     session_id="session_123",
            ...     metrics=[
            ...         {"name": "accuracy"},
            ...         # Note: No 'id' or 'name' in dict when using CustomScoreEvaluationMetric
            ...         {"score": custom_metric},  # Dynamic score calculation
            ...     ],
            ... )
        """
        # Validate that exactly one of session_id or inference_result_id is provided
        if session_id and inference_result_id:
            raise ValueError("Provide either 'session_id' or 'inference_result_id', not both.")
        if not session_id and not inference_result_id:
            raise ValueError("Either 'session_id' or 'inference_result_id' must be provided.")

        # Validate specification_ids if provided
        if specification_ids:
            for spec_id in specification_ids:
                if not is_valid_id(spec_id):
                    raise ValueError(f"Specification ID '{spec_id}' is not valid.")

        metrics = metrics or []

        # Handle inference_result_id case (single turn evaluation)
        if inference_result_id:
            if not is_valid_id(inference_result_id):
                raise ValueError("Inference result ID provided is not valid.")

            # Fetch the inference result
            inference_result = self.__inference_result_service.get(inference_result_id)

            # Get session for test case lookup
            session = self.__session_service.get(inference_result.session_id)

            # Get test case if available
            test_case: Union[TestCase, None] = None
            if session.test_case_id is not None:
                test_case = self.__test_case_service.get(session.test_case_id)

            # Process metrics with score calculation
            api_metrics = process_metrics_with_score_calculation(
                metrics=metrics,
                input_data=inference_result.actual_input,
                actual_output=inference_result.actual_output,
                expected_output=test_case.expected_output if test_case else None,
                retrieval_context=inference_result.retrieval_context,
                context=test_case.context if test_case else None,
                inference_results=[inference_result],
            )

            request_body = {
                "inferenceResultId": inference_result_id,
            }
            if specification_ids:
                request_body["specificationIds"] = specification_ids
            if api_metrics:
                request_body["metrics"] = api_metrics

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

        # Handle session_id case (full session evaluation)
        # At this point, session_id is guaranteed to be provided (not None) since we validated
        # that exactly one of session_id or inference_result_id must be provided above
        assert session_id is not None  # Type narrowing for static analysis
        if not is_valid_id(session_id):
            raise ValueError("Session ID provided is not valid.")

        # Fetch session and related data for score calculation
        session: Union[Session, None] = None
        test_case: Union[TestCase, None] = None
        latest_result: Optional[InferenceResult] = None
        all_inference_results: Optional[List[InferenceResult]] = None

        # Only fetch data if we have metrics that might need it
        if metrics:
            session = self.__session_service.get(session_id)

            # Get test case if available
            if session.test_case_id is not None:
                test_case = self.__test_case_service.get(session.test_case_id)

            # Get all inference results for this session (chronological order)
            all_inference_results = self.__inference_result_service.list(
                session_id=session_id,
                sort_by_created_at="asc",
            )

            if not all_inference_results:
                raise ValueError(f"No inference results found for session '{session_id}'")

            latest_result = all_inference_results[-1]

        # Process metrics and calculate scores for CustomScoreEvaluationMetric
        api_metrics = process_metrics_with_score_calculation(
            metrics=metrics,
            input_data=latest_result.actual_input if latest_result else None,
            actual_output=latest_result.actual_output if latest_result else None,
            expected_output=test_case.expected_output if test_case else None,
            retrieval_context=latest_result.retrieval_context if latest_result else None,
            context=test_case.context if test_case else None,
            inference_results=all_inference_results if all_inference_results else None,
        )

        request_body = {
            "sessionId": session_id,
        }
        if specification_ids:
            request_body["specificationIds"] = specification_ids
        if api_metrics:
            request_body["metrics"] = api_metrics

        response = self.__client.post("evaluations/fromSession", json=request_body)
        evaluations = [Evaluation(**evaluation) for evaluation in response.json()]
        _warn_if_legacy(evaluations)
        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,
        status: Optional[Union[EvaluationStatus, List[EvaluationStatus]]] = None,
        from_created_at: Optional[Union[str, int]] = None,
        to_created_at: Optional[Union[str, int]] = None,
        sort_by_created_at: Optional[Union[str, SortOrder]] = None,
        sort_by_score: Optional[Union[str, SortOrder]] = 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.
            status (str | list[str], optional): Filter by evaluation status. Valid values are
                'PENDING', 'SUCCESS', 'FAILED'.
            from_created_at (str | int, optional): Filter evaluations 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 evaluations 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'.
            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
        sort_by_created_at = normalize_sort_order(sort_by_created_at) if sort_by_created_at is not None else None
        sort_by_score = normalize_sort_order(sort_by_score) if sort_by_score is not None else None

        # 3. Validate and normalize status filter
        valid_statuses = [s.value for s in EvaluationStatus]
        if status is not None:
            statuses = [status] if not isinstance(status, list) else status
            # Convert enums to their string values
            statuses = [s.value if isinstance(s, EvaluationStatus) else s for s in statuses]
            for s in statuses:
                if s.upper() not in valid_statuses:
                    raise ValueError(f"Invalid status '{s}'. Must be one of: {valid_statuses}")
        else:
            statuses = None

        # 4. Normalize date filter parameters (accept ISO 8601 string or Unix timestamp)
        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,
            versionIds=version_ids,
            testCaseIds=test_case_ids,
            testIds=test_ids,
            metricIds=metric_ids,
            statuses=statuses,
            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 []),
                *(["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}")

    def retry(
        self,
        id: Union[str, List[str]],
    ) -> Dict:
        """
        Retry one or more failed evaluations.

        Only evaluations with status FAILED can be retried. Non-admin users are limited
        to do a fixed number of retries per evaluation. For more support, contact
        support@galtea.ai.

        Args:
            id (str | list[str]): Single evaluation ID or list of evaluation IDs to retry.

        Returns:
            dict: A dictionary containing:
                - retried (int): Number of evaluations successfully queued for retry.
                - skipped (int): Number of evaluations skipped (not FAILED or exceeded max retries).
                - errors (list[str]): Error messages for failed retries.

        Raises:
            ValueError: If any evaluation ID is invalid.
        """
        ids = [id] if isinstance(id, str) else id

        if not ids:
            raise ValueError("At least one evaluation ID must be provided.")

        for id in ids:
            if not is_valid_id(id):
                raise ValueError(f"Evaluation ID '{id}' is not valid.")

        response = self.__client.post("evaluations/retry", json={"ids": ids})
        return response.json()
