from __future__ import annotations

import json
from enum import Enum
from typing import TYPE_CHECKING, Any

from mlflow.data import Dataset
from mlflow.data.evaluation_dataset_source import EvaluationDatasetSource
from mlflow.data.pyfunc_dataset_mixin import PyFuncConvertibleDatasetMixin
from mlflow.entities._mlflow_object import _MlflowObject
from mlflow.entities.dataset_record import DatasetRecord
from mlflow.entities.dataset_record_source import DatasetRecordSourceType
from mlflow.exceptions import MlflowException
from mlflow.protos.datasets_pb2 import Dataset as ProtoDataset
from mlflow.telemetry.events import DatasetToDataFrameEvent, MergeRecordsEvent
from mlflow.telemetry.track import record_usage_event
from mlflow.tracing.constant import TraceMetadataKey
from mlflow.tracking.context import registry as context_registry
from mlflow.utils.mlflow_tags import MLFLOW_USER

if TYPE_CHECKING:
    import pandas as pd

    from mlflow.entities.trace import Trace


SESSION_IDENTIFIER_FIELDS = frozenset({"goal"})
SESSION_INPUT_FIELDS = frozenset({"persona", "goal", "context"})
SESSION_ALLOWED_COLUMNS = SESSION_INPUT_FIELDS | {"expectations", "tags", "source"}


class DatasetGranularity(Enum):
    TRACE = "trace"
    SESSION = "session"
    UNKNOWN = "unknown"


class EvaluationDataset(_MlflowObject, Dataset, PyFuncConvertibleDatasetMixin):
    """
    Evaluation dataset for storing inputs and expectations for GenAI evaluation.

    This class supports lazy loading of records - when retrieved via get_evaluation_dataset(),
    only metadata is loaded. Records are fetched when to_df() or merge_records() is called.
    """

    def __init__(
        self,
        dataset_id: str,
        name: str,
        digest: str,
        created_time: int,
        last_update_time: int,
        tags: dict[str, Any] | None = None,
        schema: str | None = None,
        profile: str | None = None,
        created_by: str | None = None,
        last_updated_by: str | None = None,
    ):
        """Initialize the EvaluationDataset."""
        self.dataset_id = dataset_id
        self.created_time = created_time
        self.last_update_time = last_update_time
        self.tags = tags
        self._schema = schema
        self._profile = profile
        self.created_by = created_by
        self.last_updated_by = last_updated_by
        self._experiment_ids = None
        self._records = None

        source = EvaluationDatasetSource(dataset_id=self.dataset_id)
        Dataset.__init__(self, source=source, name=name, digest=digest)

    def _compute_digest(self) -> str:
        """
        Compute digest for the dataset. This is called by Dataset.__init__ if no digest is provided.
        Since we always have a digest from the dataclass initialization, this should not be called.
        """
        return self.digest

    @property
    def source(self) -> EvaluationDatasetSource:
        """Override source property to return the correct type."""
        return self._source

    @property
    def schema(self) -> str | None:
        """
        Dataset schema information.
        """
        return self._schema

    @property
    def profile(self) -> str | None:
        """
        Dataset profile information.
        """
        return self._profile

    @property
    def experiment_ids(self) -> list[str]:
        """
        Get associated experiment IDs, loading them if necessary.

        This property implements lazy loading - experiment IDs are only fetched from the backend
        when accessed for the first time.
        """
        if self._experiment_ids is None:
            self._load_experiment_ids()
        return self._experiment_ids or []

    @experiment_ids.setter
    def experiment_ids(self, value: list[str]):
        """Set experiment IDs directly."""
        self._experiment_ids = value or []

    def _load_experiment_ids(self):
        """Load experiment IDs from the backend."""
        from mlflow.tracking._tracking_service.utils import _get_store

        tracking_store = _get_store()
        self._experiment_ids = tracking_store.get_dataset_experiment_ids(self.dataset_id)

    @property
    def records(self) -> list[DatasetRecord]:
        """
        Get dataset records, loading them if necessary.

        This property implements lazy loading - records are only fetched from the backend
        when accessed for the first time.
        """
        if self._records is None:
            from mlflow.tracking._tracking_service.utils import _get_store

            tracking_store = _get_store()
            # For lazy loading, we want all records (no pagination)
            self._records, _ = tracking_store._load_dataset_records(
                self.dataset_id, max_results=None
            )
        return self._records or []

    def has_records(self) -> bool:
        """Check if dataset records are loaded without triggering a load."""
        return self._records is not None

    def _process_trace_records(self, traces: list["Trace"]) -> list[dict[str, Any]]:
        """Convert a list of Trace objects to dataset record dictionaries.

        Args:
            traces: List of Trace objects to convert

        Returns:
            List of dictionaries with 'inputs', 'expectations', and 'source' fields
        """
        from mlflow.entities.trace import Trace

        record_dicts = []
        for i, trace in enumerate(traces):
            if not isinstance(trace, Trace):
                raise MlflowException.invalid_parameter_value(
                    f"Mixed types in trace list. Expected all elements to be Trace objects, "
                    f"but element at index {i} is {type(trace).__name__}"
                )

            root_span = trace.data._get_root_span()
            inputs = root_span.inputs if root_span and root_span.inputs is not None else {}
            outputs = root_span.outputs if root_span and root_span.outputs is not None else None

            expectations = {}
            expectation_assessments = trace.search_assessments(type="expectation")
            for expectation in expectation_assessments:
                expectations[expectation.name] = expectation.value

            # Preserve session metadata from the original trace
            source_data = {"trace_id": trace.info.trace_id}
            if session_id := trace.info.trace_metadata.get(TraceMetadataKey.TRACE_SESSION):
                source_data["session_id"] = session_id

            record_dict = {
                "inputs": inputs,
                "outputs": outputs,
                "expectations": expectations,
                "source": {
                    "source_type": DatasetRecordSourceType.TRACE.value,
                    "source_data": source_data,
                },
            }
            record_dicts.append(record_dict)

        return record_dicts

    def _process_dataframe_records(self, df: "pd.DataFrame") -> list[dict[str, Any]]:
        """Process a DataFrame into dataset record dictionaries.

        Args:
            df: DataFrame to process. Can be either:
                - DataFrame from search_traces with 'trace' column containing Trace objects/JSON
                - Standard DataFrame with 'inputs', 'expectations' columns

        Returns:
            List of dictionaries with 'inputs', 'expectations', and optionally 'source' fields
        """
        if "trace" in df.columns:
            from mlflow.entities.trace import Trace

            traces = [
                Trace.from_json(trace_item) if isinstance(trace_item, str) else trace_item
                for trace_item in df["trace"]
            ]

            return self._process_trace_records(traces)
        else:
            return df.to_dict("records")

    @record_usage_event(MergeRecordsEvent)
    def merge_records(
        self, records: list[dict[str, Any]] | "pd.DataFrame" | list["Trace"]
    ) -> "EvaluationDataset":
        """
        Merge new records with existing ones.

        Args:
            records: Records to merge. Can be:
                - List of dictionaries with 'inputs' and optionally 'expectations' and 'tags'
                - Session format with 'persona', 'goal', 'context' nested inside 'inputs'
                - DataFrame from mlflow.search_traces() - automatically parsed and converted
                - DataFrame with 'inputs' column and optionally 'expectations' and 'tags' columns
                - List of Trace objects

        Returns:
            Self for method chaining

        Example:
            .. code-block:: python

                # Direct usage with search_traces DataFrame output
                traces_df = mlflow.search_traces()  # Returns DataFrame by default
                dataset.merge_records(traces_df)  # No extraction needed

                # Or with standard DataFrame
                df = pd.DataFrame([{"inputs": {"q": "What?"}, "expectations": {"a": "Answer"}}])
                dataset.merge_records(df)

                # Session format in inputs
                test_cases = [
                    {
                        "inputs": {
                            "persona": "Student",
                            "goal": "Find articles",
                            "context": {"student_id": "U1"},
                        }
                    },
                ]
                dataset.merge_records(test_cases)
        """
        import pandas as pd

        from mlflow.entities.trace import Trace
        from mlflow.tracking._tracking_service.utils import _get_store, get_tracking_uri

        if isinstance(records, pd.DataFrame):
            record_dicts = self._process_dataframe_records(records)
        elif isinstance(records, list) and records and isinstance(records[0], Trace):
            record_dicts = self._process_trace_records(records)
        else:
            record_dicts = records

        self._validate_record_dicts(record_dicts)

        self._infer_source_types(record_dicts)

        tracking_store = _get_store()

        try:
            existing_dataset = tracking_store.get_dataset(self.dataset_id)
            self._schema = existing_dataset.schema
        except Exception as e:
            raise MlflowException.invalid_parameter_value(
                f"Cannot add records to dataset {self.dataset_id}: Dataset not found. "
                f"Please verify the dataset exists and check your tracking URI is set correctly "
                f"(currently set to: {get_tracking_uri()})."
            ) from e

        self._validate_schema(record_dicts)

        context_tags = context_registry.resolve_tags()
        if user_tag := context_tags.get(MLFLOW_USER):
            for record in record_dicts:
                if "tags" not in record:
                    record["tags"] = {}
                if MLFLOW_USER not in record["tags"]:
                    record["tags"][MLFLOW_USER] = user_tag

        tracking_store.upsert_dataset_records(dataset_id=self.dataset_id, records=record_dicts)
        self._records = None

        return self

    def _validate_record_dicts(self, record_dicts: list[dict[str, Any]]) -> None:
        """Validate that record dictionaries have the required structure.

        Args:
            record_dicts: List of record dictionaries to validate

        Raises:
            MlflowException: If records don't have the required structure
        """
        for record in record_dicts:
            if not isinstance(record, dict):
                raise MlflowException.invalid_parameter_value("Each record must be a dictionary")
            if "inputs" not in record:
                raise MlflowException.invalid_parameter_value(
                    "Each record must have an 'inputs' field"
                )

    def _infer_source_types(self, record_dicts: list[dict[str, Any]]) -> None:
        """Infer source types for records without explicit source information.

        Simple inference rules:
        - Records with expectations -> HUMAN (manual test cases/ground truth)
        - Records with inputs but no expectations -> CODE (programmatically generated)

        Inference can be overridden by providing explicit source information.

        Note that trace inputs (from List[Trace] or pd.DataFrame of Trace data) will
        always be inferred as a trace source type when processing trace records.

        Args:
            record_dicts: List of record dictionaries to process (modified in place)
        """
        for record in record_dicts:
            if "source" in record:
                continue

            if "expectations" in record and record["expectations"]:
                record["source"] = {
                    "source_type": DatasetRecordSourceType.HUMAN.value,
                    "source_data": {},
                }
            elif "inputs" in record and "expectations" not in record:
                record["source"] = {
                    "source_type": DatasetRecordSourceType.CODE.value,
                    "source_data": {},
                }

    def _validate_schema(self, record_dicts: list[dict[str, Any]]) -> None:
        """
        Validate schema consistency of new records and compatibility with existing dataset.

        Args:
            record_dicts: List of normalized record dictionaries

        Raises:
            MlflowException: If records have invalid schema, inconsistent schemas within batch,
                or are incompatible with existing dataset schema
        """
        granularity_counts: dict[DatasetGranularity, int] = {}

        for record in record_dicts:
            input_keys = set(record.get("inputs", {}).keys())
            if not input_keys:
                continue

            record_type = self._classify_input_fields(input_keys)

            if record_type == DatasetGranularity.UNKNOWN:
                session_fields = input_keys & SESSION_IDENTIFIER_FIELDS
                other_fields = input_keys - SESSION_INPUT_FIELDS
                raise MlflowException.invalid_parameter_value(
                    f"Invalid input schema: cannot mix session fields {list(session_fields)} "
                    f"with other fields {list(other_fields)}. "
                    f"Consider placing {list(other_fields)} fields inside 'context'."
                )

            granularity_counts[record_type] = granularity_counts.get(record_type, 0) + 1

        if len(granularity_counts) > 1:
            counts_str = ", ".join(
                f"{count} records with {granularity.value} granularity"
                for granularity, count in granularity_counts.items()
            )
            raise MlflowException.invalid_parameter_value(
                f"All records must use the same granularity. Found {counts_str}."
            )

        batch_granularity = next(iter(granularity_counts), DatasetGranularity.UNKNOWN)
        existing_granularity = self._get_existing_granularity()

        if DatasetGranularity.UNKNOWN in {batch_granularity, existing_granularity}:
            return

        if batch_granularity != existing_granularity:
            raise MlflowException.invalid_parameter_value(
                f"New records use {batch_granularity.value} granularity, but existing "
                f"dataset uses {existing_granularity.value}. Cannot mix granularities."
            )

    def _get_existing_granularity(self) -> DatasetGranularity:
        """
        Get granularity from the dataset's stored schema.

        Returns:
            DatasetGranularity based on existing records, or UNKNOWN if empty/unparseable
        """
        if self._schema is None:
            if self.has_records():
                return self._classify_input_fields(set(self.records[0].inputs.keys()))
            return DatasetGranularity.UNKNOWN
        try:
            schema = json.loads(self._schema)
            input_keys = set(schema.get("inputs", {}).keys())
            return self._classify_input_fields(input_keys)
        except (json.JSONDecodeError, TypeError):
            return DatasetGranularity.UNKNOWN

    @staticmethod
    def _classify_input_fields(input_keys: set[str]) -> DatasetGranularity:
        """
        Classify a set of input field names into a granularity type:
        - SESSION: Has 'goal' field, and only session fields (persona, goal, context)
        - TRACE: No 'goal' field present
        - UNKNOWN: Empty or has 'goal' mixed with non-session fields

        Args:
            input_keys: Set of field names from a record's inputs

        Returns:
            DatasetGranularity classification for the input fields
        """
        if not input_keys:
            return DatasetGranularity.UNKNOWN

        has_session_identifier = bool(input_keys & SESSION_IDENTIFIER_FIELDS)

        if not has_session_identifier:
            return DatasetGranularity.TRACE

        if input_keys <= SESSION_INPUT_FIELDS:
            return DatasetGranularity.SESSION

        return DatasetGranularity.UNKNOWN

    @record_usage_event(DatasetToDataFrameEvent)
    def to_df(self) -> "pd.DataFrame":
        """
        Convert dataset records to a pandas DataFrame.

        This method triggers lazy loading of records if they haven't been loaded yet.

        Returns:
            DataFrame with columns for inputs, outputs, expectations, tags, and metadata
        """
        import pandas as pd

        records = self.records

        if not records:
            return pd.DataFrame(
                columns=[
                    "inputs",
                    "outputs",
                    "expectations",
                    "tags",
                    "source_type",
                    "source_id",
                    "source",
                    "created_time",
                    "dataset_record_id",
                ]
            )

        data = [
            {
                "inputs": record.inputs,
                "outputs": record.outputs,
                "expectations": record.expectations,
                "tags": record.tags,
                "source_type": record.source_type,
                "source_id": record.source_id,
                "source": record.source,
                "created_time": record.created_time,
                "dataset_record_id": record.dataset_record_id,
            }
            for record in records
        ]

        return pd.DataFrame(data)

    def to_proto(self) -> ProtoDataset:
        """Convert to protobuf representation."""
        proto = ProtoDataset()

        proto.dataset_id = self.dataset_id
        proto.name = self.name
        if self.tags is not None:
            proto.tags = json.dumps(self.tags)
        if self.schema is not None:
            proto.schema = self.schema
        if self.profile is not None:
            proto.profile = self.profile
        proto.digest = self.digest
        proto.created_time = self.created_time
        proto.last_update_time = self.last_update_time
        if self.created_by is not None:
            proto.created_by = self.created_by
        if self.last_updated_by is not None:
            proto.last_updated_by = self.last_updated_by
        if self._experiment_ids is not None:
            proto.experiment_ids.extend(self._experiment_ids)

        return proto

    @classmethod
    def from_proto(cls, proto: ProtoDataset) -> "EvaluationDataset":
        """Create instance from protobuf representation."""
        tags = None
        if proto.HasField("tags"):
            tags = json.loads(proto.tags)

        dataset = cls(
            dataset_id=proto.dataset_id,
            name=proto.name,
            digest=proto.digest,
            created_time=proto.created_time,
            last_update_time=proto.last_update_time,
            tags=tags,
            schema=proto.schema if proto.HasField("schema") else None,
            profile=proto.profile if proto.HasField("profile") else None,
            created_by=proto.created_by if proto.HasField("created_by") else None,
            last_updated_by=proto.last_updated_by if proto.HasField("last_updated_by") else None,
        )
        if proto.experiment_ids:
            dataset._experiment_ids = list(proto.experiment_ids)
        return dataset

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary representation."""
        result = super().to_dict()

        result.update(
            {
                "dataset_id": self.dataset_id,
                "tags": self.tags,
                "schema": self.schema,
                "profile": self.profile,
                "created_time": self.created_time,
                "last_update_time": self.last_update_time,
                "created_by": self.created_by,
                "last_updated_by": self.last_updated_by,
                "experiment_ids": self.experiment_ids,
            }
        )

        result["records"] = [record.to_dict() for record in self.records]

        return result

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "EvaluationDataset":
        """Create instance from dictionary representation."""
        if "dataset_id" not in data:
            raise ValueError("dataset_id is required")
        if "name" not in data:
            raise ValueError("name is required")
        if "digest" not in data:
            raise ValueError("digest is required")
        if "created_time" not in data:
            raise ValueError("created_time is required")
        if "last_update_time" not in data:
            raise ValueError("last_update_time is required")

        dataset = cls(
            dataset_id=data["dataset_id"],
            name=data["name"],
            digest=data["digest"],
            created_time=data["created_time"],
            last_update_time=data["last_update_time"],
            tags=data.get("tags"),
            schema=data.get("schema"),
            profile=data.get("profile"),
            created_by=data.get("created_by"),
            last_updated_by=data.get("last_updated_by"),
        )
        if "experiment_ids" in data:
            dataset._experiment_ids = data["experiment_ids"]

        if "records" in data:
            dataset._records = [
                DatasetRecord.from_dict(record_data) for record_data in data["records"]
            ]

        return dataset
