"""Eval assertion engine — evaluate tool response data against assertions.

Each assertion function returns a tuple of ``(passed, reason)`` where *passed*
is a bool and *reason* is a human-readable explanation.

Unlike the harness assertions which work on raw ToolCallResult objects, these
eval assertions work on parsed response data (dicts/lists) extracted from the
tool's text content.
"""

from __future__ import annotations

import json
from typing import Any

import jsonschema
from jsonpath_ng import parse as jsonpath_parse
from jsonpath_ng.exceptions import JsonPathParserError

from hatchdx.eval.models import EvalAssertion, EvalError

AssertionResult = tuple[bool, str]


def evaluate_assertion(assertion: EvalAssertion, data: Any) -> AssertionResult:
    """Dispatch to the correct assertion evaluator based on type."""
    evaluators = {
        "schema": _assert_schema,
        "range": _assert_range,
        "length": _assert_length,
        "contains": _assert_contains,
        # golden_file is handled separately by the golden engine
    }

    evaluator = evaluators.get(assertion.type)
    if evaluator is None:
        if assertion.type == "golden_file":
            # Golden file assertions are evaluated by the golden engine,
            # not here. Return a placeholder.
            return True, "Golden file assertion (evaluated separately)"
        return False, f"Unknown assertion type: {assertion.type}"

    return evaluator(assertion, data)


def _assert_schema(assertion: EvalAssertion, data: Any) -> AssertionResult:
    """Validate data against a JSON Schema."""
    if assertion.schema is None:
        return False, "Schema assertion is missing 'schema' definition"

    try:
        jsonschema.validate(instance=data, schema=assertion.schema)
    except jsonschema.ValidationError as exc:
        return False, f"Schema validation failed: {exc.message}"
    except jsonschema.SchemaError as exc:
        return False, f"Invalid JSON Schema: {exc.message}"

    return True, "Data matches JSON Schema"


def _assert_range(assertion: EvalAssertion, data: Any) -> AssertionResult:
    """Extract a value via JSONPath and check it falls within min/max bounds."""
    if assertion.path is None:
        return False, "Range assertion is missing 'path'"

    value = _extract_jsonpath(assertion.path, data)
    if value is _MISSING:
        return False, f"JSONPath '{assertion.path}' matched nothing in the response"

    try:
        numeric_value = float(value)
    except (TypeError, ValueError):
        return False, (
            f"JSONPath '{assertion.path}' resolved to {value!r} "
            f"which is not numeric"
        )

    if assertion.min is not None and numeric_value < assertion.min:
        return False, (
            f"Value {numeric_value} at '{assertion.path}' is below minimum {assertion.min}"
        )

    if assertion.max is not None and numeric_value > assertion.max:
        return False, (
            f"Value {numeric_value} at '{assertion.path}' is above maximum {assertion.max}"
        )

    bounds_desc = _bounds_description(assertion.min, assertion.max)
    return True, f"Value {numeric_value} at '{assertion.path}' is within {bounds_desc}"


def _assert_length(assertion: EvalAssertion, data: Any) -> AssertionResult:
    """Extract a value via JSONPath and check its length."""
    if assertion.path is None:
        return False, "Length assertion is missing 'path'"

    value = _extract_jsonpath(assertion.path, data)
    if value is _MISSING:
        return False, f"JSONPath '{assertion.path}' matched nothing in the response"

    try:
        actual_length = len(value)
    except TypeError:
        return False, (
            f"JSONPath '{assertion.path}' resolved to {type(value).__name__} "
            f"which has no length"
        )

    expected = assertion.expected
    try:
        expected_length = int(expected)
    except (TypeError, ValueError):
        return False, f"Expected length '{expected}' is not a valid integer"

    if actual_length != expected_length:
        return False, (
            f"Length of '{assertion.path}' is {actual_length}, expected {expected_length}"
        )

    return True, f"Length of '{assertion.path}' is {actual_length} as expected"


def _assert_contains(assertion: EvalAssertion, data: Any) -> AssertionResult:
    """Extract a value via JSONPath and check it contains a substring."""
    if assertion.path is None:
        return False, "Contains assertion is missing 'path'"

    value = _extract_jsonpath(assertion.path, data)
    if value is _MISSING:
        return False, f"JSONPath '{assertion.path}' matched nothing in the response"

    expected = assertion.expected
    if expected is None:
        return False, "Contains assertion is missing 'expected' value"

    str_value = str(value)
    str_expected = str(expected)

    if str_expected in str_value:
        return True, f"Value at '{assertion.path}' contains '{str_expected}'"

    # Truncate long values for readability
    display_value = str_value if len(str_value) <= 100 else str_value[:97] + "..."
    return False, (
        f"Value at '{assertion.path}' does not contain '{str_expected}'. "
        f"Got: {display_value}"
    )


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_MISSING = object()  # Sentinel for "no match found"


def _extract_jsonpath(path: str, data: Any) -> Any:
    """Extract a single value from data using a JSONPath expression.

    Returns the matched value, or ``_MISSING`` if no match was found.
    """
    try:
        expression = jsonpath_parse(path)
    except (JsonPathParserError, Exception) as exc:
        raise EvalError(f"Invalid JSONPath expression '{path}': {exc}") from exc

    matches = expression.find(data)
    if not matches:
        return _MISSING

    # Return the first match's value
    return matches[0].value


def _bounds_description(min_val: float | None, max_val: float | None) -> str:
    """Build a human-readable description of min/max bounds."""
    if min_val is not None and max_val is not None:
        return f"range [{min_val}, {max_val}]"
    if min_val is not None:
        return f">= {min_val}"
    if max_val is not None:
        return f"<= {max_val}"
    return "unbounded range"


def parse_tool_response_data(content: list[dict[str, Any]]) -> Any:
    """Extract structured data from MCP tool response content blocks.

    Joins all text blocks and attempts to parse as JSON. If the content
    is not valid JSON, returns the raw text string.
    """
    text_parts: list[str] = []
    for block in content:
        if isinstance(block, dict) and block.get("type") == "text":
            text_parts.append(block.get("text", ""))

    full_text = "\n".join(text_parts)
    if not full_text:
        return {}

    try:
        return json.loads(full_text)
    except json.JSONDecodeError:
        # Return raw text — some assertions may still work on strings
        return full_text
