# TrustEval — Enterprise LLM Evaluation Framework
# Author: Antrixsh Gupta | https://www.linkedin.com/in/antrixshgupta
# License: MIT

"""Score aggregation, normalisation, and grading utilities.

Provides weighted-average scoring across pillars and maps numeric scores
to letter grades and trust levels used in evaluation reports.
"""

from __future__ import annotations

from typing import Dict

from trusteval.utils.exceptions import ScoringError, ValidationError
from trusteval.utils.validators import validate_score

# ---------------------------------------------------------------------------
# Grade / trust-level boundaries
# ---------------------------------------------------------------------------

_GRADE_THRESHOLDS: list[tuple[float, str]] = [
    (0.85, "A"),
    (0.70, "B"),
    (0.55, "C"),
    (0.40, "D"),
    (0.00, "F"),
]

_TRUST_MAP: dict[str, str] = {
    "A": "TRUSTED",
    "B": "TRUSTED",
    "C": "CONDITIONAL",
    "D": "CONDITIONAL",
    "F": "UNTRUSTED",
}


def compute_grade(score: float) -> str:
    """Map a normalised score to a letter grade.

    Args:
        score: A float in ``[0.0, 1.0]``.

    Returns:
        One of ``"A"``, ``"B"``, ``"C"``, ``"D"``, ``"F"``.

    Raises:
        ValidationError: If *score* is outside [0, 1].
    """
    score = validate_score(score)
    for threshold, grade in _GRADE_THRESHOLDS:
        if score >= threshold:
            return grade
    return "F"  # pragma: no cover — unreachable, but defensive


def compute_trust_level(grade: str) -> str:
    """Map a letter grade to a trust level.

    Args:
        grade: One of ``"A"`` through ``"F"``.

    Returns:
        ``"TRUSTED"``, ``"CONDITIONAL"``, or ``"UNTRUSTED"``.

    Raises:
        ScoringError: If *grade* is not a recognised letter grade.
    """
    level = _TRUST_MAP.get(grade.upper())
    if level is None:
        raise ScoringError(f"Unknown grade '{grade}'. Expected A-F.")
    return level


class Scorer:
    """Weighted score aggregator for multi-pillar evaluations.

    Args:
        weights: Mapping of pillar name to its weight.  Weights are
            automatically normalised so they sum to 1.0.

    Raises:
        ScoringError: If *weights* is empty or contains non-positive values.
    """

    def __init__(self, weights: Dict[str, float]) -> None:
        if not weights:
            raise ScoringError("At least one pillar weight is required.")
        for name, w in weights.items():
            if w < 0:
                raise ScoringError(
                    f"Weight for pillar '{name}' must be non-negative, got {w}"
                )

        total = sum(weights.values())
        if total <= 0:
            raise ScoringError("Sum of pillar weights must be positive.")

        # Normalise so weights sum to 1.0
        self._weights: Dict[str, float] = {
            k: v / total for k, v in weights.items()
        }

    @property
    def weights(self) -> Dict[str, float]:
        """Return a copy of the normalised weights."""
        return dict(self._weights)

    def weighted_average(self, scores: Dict[str, float]) -> float:
        """Compute the weighted average over pillar scores.

        Args:
            scores: Mapping of pillar name to its score in ``[0.0, 1.0]``.
                Only pillars present in both *scores* and the configured
                weights contribute; the weights are re-normalised over the
                intersection.

        Returns:
            Aggregated score in ``[0.0, 1.0]``.

        Raises:
            ScoringError: If the intersection of score keys and weight keys
                is empty.
        """
        common = set(scores) & set(self._weights)
        if not common:
            raise ScoringError(
                "No overlap between provided scores and configured pillar weights. "
                f"Scores: {sorted(scores)}, Weights: {sorted(self._weights)}"
            )

        # Re-normalise over common pillars
        weight_sum = sum(self._weights[k] for k in common)
        total = 0.0
        for pillar in common:
            s = validate_score(scores[pillar], field=f"scores[{pillar}]")
            total += s * (self._weights[pillar] / weight_sum)

        return round(total, 6)

    def grade(self, scores: Dict[str, float]) -> str:
        """Compute the overall letter grade from pillar scores.

        Args:
            scores: Pillar name to score mapping.

        Returns:
            A letter grade (``"A"``–``"F"``).
        """
        return compute_grade(self.weighted_average(scores))

    def trust_level(self, scores: Dict[str, float]) -> str:
        """Compute the trust level from pillar scores.

        Args:
            scores: Pillar name to score mapping.

        Returns:
            ``"TRUSTED"``, ``"CONDITIONAL"``, or ``"UNTRUSTED"``.
        """
        return compute_trust_level(self.grade(scores))
