from __future__ import annotations

from collections import defaultdict
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, Optional, Union

from mlflow.exceptions import MlflowException
from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE

SPANS_COLUMN_NAME = "spans"

if TYPE_CHECKING:
    import pandas

    import mlflow.entities
    from mlflow.entities import Trace


def traces_to_df(traces: list[Trace]) -> "pandas.DataFrame":
    """
    Convert a list of MLflow Traces to a pandas DataFrame with one column called "traces"
    containing string representations of each Trace.
    """
    import pandas as pd

    from mlflow.entities.trace import Trace  # import here to avoid circular import

    rows = [trace.to_pandas_dataframe_row() for trace in traces]
    return pd.DataFrame.from_records(data=rows, columns=Trace.pandas_dataframe_columns())


def extract_span_inputs_outputs(
    traces: Union[list["mlflow.entities.Trace"], "pandas.DataFrame"],
    fields: list[str],
    col_name: Optional[str] = None,
) -> "pandas.DataFrame":
    """
    Extracts the specified input and output fields from the spans contained in the specified traces.

    Args:
        traces: A list of :py:class:`mlflow.entities.Trace` or a pandas DataFrame containing traces.
        fields: A list of field strings of the form 'span_name.[inputs|outputs]' or
            'span_name.[inputs|outputs].field_name'.
        col_name: The name of the column in the traces DataFrame containing the spans. If `traces`
            is a list of MLflow Traces, this argument should not be provided.
    """
    try:
        import pandas as pd
    except ImportError as e:
        raise MlflowException(
            message=(
                "The `pandas` library is not installed. Please install `pandas` to use the"
                f"`mlflow.tracing.extract` function. Error: {e}"
            ),
        )

    parsed_fields = _parse_fields(fields)

    if isinstance(traces, list):
        if col_name is not None:
            raise MlflowException(
                message=(
                    "If `traces` is a list of MLflow Traces, `col_name` should not be provided."
                ),
                error_code=INVALID_PARAMETER_VALUE,
            )
        traces = traces_to_df(traces)
        col_name = SPANS_COLUMN_NAME

    if isinstance(traces, pd.DataFrame):
        return _extract_from_traces_pandas_df(df=traces, col_name=col_name, fields=parsed_fields)

    raise MlflowException(
        message=(
            "`traces` must be a list of MLflow Traces or a pandas DataFrame. Got: {type(traces)}"
        ),
        error_code=INVALID_PARAMETER_VALUE,
    )


class _PeekableIterator:
    """
    Wraps an iterator and allows peeking at the next element without consuming it.
    """

    def __init__(self, it):
        self.it = iter(it)
        self._next = None

    def __iter__(self):
        return self

    def __next__(self):
        if self._next is not None:
            next_value = self._next
            self._next = None
            return next_value
        return next(self.it)

    def peek(self):
        if self._next is None:
            try:
                self._next = next(self.it)
            except StopIteration:
                return None
        return self._next


class _ParsedField(NamedTuple):
    """
    Represents a parsed field from a string of the form 'span_name.[inputs|outputs]' or
    'span_name.[inputs|outputs].field_name'.
    """

    span_name: str
    field_type: Literal["inputs", "outputs"]
    field_name: Optional[str]

    def __str__(self) -> str:
        return (
            f"{self.span_name}.{self.field_type}.{self.field_name}"
            if self.field_name is not None
            else f"{self.span_name}.{self.field_type}"
        )


_BACKTICK = "`"


class _FieldParser:
    def __init__(self, field: str) -> None:
        self.field = field
        self.chars = _PeekableIterator(field)

    def peek(self) -> str:
        return self.chars.peek()

    def next(self) -> str:
        return next(self.chars)

    def has_next(self) -> bool:
        return self.peek() is not None

    def consume_until_char_or_end(self, stop_char: Optional[str] = None) -> str:
        """
        Consume characters until the specified character is encountered or the end of the
        string. If char is None, consume until the end of the string.
        """
        consumed = ""
        while (c := self.peek()) and c != stop_char:
            consumed += self.next()
        return consumed

    def _parse_span_name(self) -> str:
        if self.peek() == _BACKTICK:
            self.next()
            span_name = self.consume_until_char_or_end(_BACKTICK)
            if self.peek() != _BACKTICK:
                raise MlflowException.invalid_parameter_value(
                    f"Expected closing backtick: {self.field!r}"
                )
            self.next()
        else:
            span_name = self.consume_until_char_or_end(".")

        if self.peek() != ".":
            raise MlflowException.invalid_parameter_value(
                f"Expected dot after span name: {self.field!r}"
            )
        self.next()
        return span_name

    def _parse_field_type(self) -> str:
        field_type = self.consume_until_char_or_end(".")
        if field_type not in ("inputs", "outputs"):
            raise MlflowException.invalid_parameter_value(
                f"Invalid field type: {field_type!r}. Expected 'inputs' or 'outputs'."
            )

        if self.has_next():
            self.next()  # Consume the dot
        return field_type

    def _parse_field_name(self) -> str:
        if self.peek() == _BACKTICK:
            self.next()
            field_name = self.consume_until_char_or_end(_BACKTICK)
            if self.peek() != _BACKTICK:
                raise MlflowException.invalid_parameter_value(
                    f"Expected closing backtick: {self.field!r}"
                )
            self.next()

            # There should be no more characters after the closing backtick
            if self.has_next():
                raise MlflowException.invalid_parameter_value(
                    f"Unexpected characters after closing backtick: {self.field!r}"
                )

        else:
            field_name = self.consume_until_char_or_end()

        return field_name

    def parse(self) -> _ParsedField:
        span_name = self._parse_span_name()
        field_type = self._parse_field_type()
        field_name = self._parse_field_name() if self.has_next() else None
        return _ParsedField(span_name=span_name, field_type=field_type, field_name=field_name)


def _parse_fields(fields: list[str]) -> list[_ParsedField]:
    """
    Parses the specified field strings of the form 'span_name.[inputs|outputs]' or
    'span_name.[inputs|outputs].field_name' into _ParsedField objects.
    """
    return [_FieldParser(field).parse() for field in fields]


def _extract_from_traces_pandas_df(
    df: "pandas.DataFrame", col_name: str, fields: list[_ParsedField]
) -> "pandas.DataFrame":
    """
    Extracts the specified fields from the spans contained in the specified column of the
    specified traces DataFrame.
    """

    from mlflow.entities import Span

    if col_name not in df.columns:
        raise MlflowException(
            message=(
                f"Column '{col_name}' not found in traces DataFrame."
                f" Available columns: {df.columns}"
            ),
            error_code=INVALID_PARAMETER_VALUE,
        )

    new_columns: dict[str, list[Any]] = defaultdict(list)
    for _, row in df.iterrows():
        spans_dict: dict[str, list[Span]] = defaultdict(list)
        for span in _extract_spans_from_row(row[col_name]):
            spans_dict[span.name].append(span)

        for field in fields:
            matching_spans = spans_dict.get(field.span_name, [])
            matching_value = _find_matching_value(field, matching_spans)
            new_columns[str(field)].append(matching_value)

    df_with_new_fields = df.copy()
    for field in fields:
        df_with_new_fields[str(field)] = new_columns[str(field)]

    return df_with_new_fields


def _find_matching_value(field: _ParsedField, spans: list["mlflow.entities.Span"]) -> Optional[Any]:
    """
    Find the value of the field in the list of spans. If the field is not found, return None.
    """
    for span in spans:
        span_inputs_or_outputs = getattr(span, field.field_type)
        if (
            isinstance(span_inputs_or_outputs, dict)
            and field.field_name is not None
            and field.field_name in span_inputs_or_outputs
        ):
            return span_inputs_or_outputs.get(field.field_name)
        elif field.field_name is None:
            return span_inputs_or_outputs


def _extract_spans_from_row(
    row_content: Optional[list[dict[str, Any]]],
) -> list["mlflow.entities.Span"]:
    """
    Parses and extracts MLflow Spans from the row content of a traces pandas DataFrame.
    """
    from mlflow.entities import Span

    if row_content is None:
        return []

    try:
        return [Span.from_dict(span_dict) for span_dict in row_content]
    except Exception as e:
        raise MlflowException(
            message=(
                f"Failed to extract spans from traces DataFrame row content: {row_content}."
                f" Error: {e}"
            ),
            error_code=INVALID_PARAMETER_VALUE,
        ) from e
