from typing import TypedDict

import pytest
from dot_metrics import BatchResult, ComputedValue, Constraint, Metric, MetricSet


def test_metric_float():
    metric_set = MetricSet()
    metric_set.add("quality", lambda x: x * 0.5, unit="%", description="Quality score", higher_is_better=True)
    result = metric_set.compute(0.8)
    assert isinstance(result.metrics["quality"], Metric)
    assert result.metrics["quality"].value == pytest.approx(0.4)
    assert result.metrics["quality"].unit == "%"
    assert result.metrics["quality"].description == "Quality score"
    assert result.metrics["quality"].higher_is_better is True
    assert result.metrics["quality"].debug == {}


def test_constraint_float():
    metric_set = MetricSet()
    metric_set.add_constraint("violations", lambda x: x, threshold=0.0, description="No violations")
    result = metric_set.compute(0.0)
    assert isinstance(result.constraints["violations"], Constraint)
    assert result.constraints["violations"].value == 0.0
    assert result.constraints["violations"].threshold == 0.0
    assert result.constraints["violations"].passed is True


def test_constraint_fails():
    metric_set = MetricSet()
    metric_set.add_constraint("violations", lambda x: x, threshold=0.0)
    result = metric_set.compute(0.1)
    assert result.constraints["violations"].passed is False


def test_metric_computed_value_debug():
    metric_set = MetricSet()
    metric_set.add("with_debug", lambda _: ComputedValue(value=0.75, debug={"detail": [1, 2, 3]}))
    result = metric_set.compute(None)
    assert result.metrics["with_debug"].value == pytest.approx(0.75)
    assert result.metrics["with_debug"].debug == {"detail": [1, 2, 3]}


def test_forwards_single_data_object():
    from dataclasses import dataclass

    @dataclass
    class Inputs:
        a: float
        b: float
        c: float

    metric_set: MetricSet[Inputs] = MetricSet()
    metric_set.add("sum", lambda data: data.a + data.b + data.c)
    result = metric_set.compute(Inputs(a=1, b=2, c=3))
    assert result.metrics["sum"].value == pytest.approx(6.0)


def test_add_constraint_requires_threshold():
    metric_set = MetricSet()
    with pytest.raises(TypeError):
        metric_set.add_constraint("foo", lambda: 0.0)  # missing threshold keyword


def test_metric_metadata():
    metric_set = MetricSet()
    metric_set.add("quality", lambda x: x * 0.5, metadata={"name": "Quality", "user_facing": True})
    result = metric_set.compute(0.8)
    assert result.metrics["quality"].metadata == {"name": "Quality", "user_facing": True}


def test_constraint_metadata():
    metric_set = MetricSet()
    metric_set.add_constraint("violations", lambda x: x, threshold=0.0, metadata={"french_name": "Violations"})
    result = metric_set.compute(0.0)
    assert result.constraints["violations"].metadata == {"french_name": "Violations"}


def test_compute_batch_list():
    metric_set = MetricSet()
    metric_set.add("score", lambda x: x * 2.0)
    batch = metric_set.compute_batch([0.1, 0.5, 0.9])
    assert isinstance(batch, BatchResult)
    assert len(batch) == 3
    assert batch[0].score("score") == pytest.approx(0.2)
    assert batch[2].score("score") == pytest.approx(1.8)


def test_compute_batch_dict():
    metric_set = MetricSet()
    metric_set.add("score", lambda x: x)
    batch = metric_set.compute_batch({"a": 0.3, "b": 0.7})
    assert batch["a"].score("score") == pytest.approx(0.3)
    assert batch["b"].score("score") == pytest.approx(0.7)


def test_compute_batch_tuple_keys():
    metric_set = MetricSet()
    metric_set.add("val", lambda x: x)
    batch = metric_set.compute_batch({(1, "en"): 0.5, (2, "fr"): 0.8})
    assert batch[(1, "en")].score("val") == pytest.approx(0.5)


def test_compute_batch_scores():
    metric_set = MetricSet()
    metric_set.add("score", lambda x: x)
    batch = metric_set.compute_batch({"x": 0.4, "y": 0.6})
    assert batch.scores("score") == {"x": pytest.approx(0.4), "y": pytest.approx(0.6)}


def test_compute_batch_items_iteration():
    metric_set = MetricSet()
    metric_set.add("val", lambda x: x)
    batch = metric_set.compute_batch([0.1, 0.2])
    assert list(batch) == [0, 1]
    assert list(k for k, _ in batch.items()) == [0, 1]


def test_compute_batch_invalid_input():
    metric_set = MetricSet()
    with pytest.raises(TypeError):
        metric_set.compute_batch("not a list or dict")


def test_metric_decorator():
    metric_set = MetricSet()

    @metric_set.metric("score")
    def score(x):
        return x * 2.0

    result = metric_set.compute(0.5)
    assert result.metrics["score"].value == pytest.approx(1.0)


def test_metric_decorator_returns_function():
    metric_set = MetricSet()

    @metric_set.metric("score")
    def score(x):
        return x * 2.0

    # Function should still be callable directly
    assert score(0.5) == 1.0


def test_metric_decorator_with_kwargs():
    metric_set = MetricSet()

    @metric_set.metric("quality", unit="%", description="Quality metric", higher_is_better=False)
    def quality(x):
        return x

    result = metric_set.compute(0.75)
    assert result.metrics["quality"].value == pytest.approx(0.75)
    assert result.metrics["quality"].unit == "%"
    assert result.metrics["quality"].description == "Quality metric"
    assert result.metrics["quality"].higher_is_better is False


def test_metric_decorator_with_metadata():
    metric_set = MetricSet()

    @metric_set.metric("score", metadata={"category": "performance"})
    def score(x):
        return x

    result = metric_set.compute(0.5)
    assert result.metrics["score"].metadata == {"category": "performance"}


def test_constraint_decorator():
    metric_set = MetricSet()

    @metric_set.constraint("errors", threshold=0)
    def errors(x):
        return x

    result = metric_set.compute(0.0)
    assert result.constraints["errors"].passed is True
    assert result.constraints["errors"].value == 0.0
    assert result.constraints["errors"].threshold == 0


def test_constraint_decorator_returns_function():
    metric_set = MetricSet()

    @metric_set.constraint("errors", threshold=0)
    def errors(x):
        return x * 2

    # Function should still be callable directly
    assert errors(5) == 10


def test_constraint_decorator_fails():
    metric_set = MetricSet()

    @metric_set.constraint("errors", threshold=0)
    def errors(x):
        return x

    result = metric_set.compute(0.5)
    assert result.constraints["errors"].passed is False


def test_constraint_decorator_with_kwargs():
    metric_set = MetricSet()

    @metric_set.constraint("violations", threshold=5, unit="count", description="Violation count")
    def violations(x):
        return x

    result = metric_set.compute(3)
    assert result.constraints["violations"].value == 3
    assert result.constraints["violations"].threshold == 5
    assert result.constraints["violations"].unit == "count"
    assert result.constraints["violations"].description == "Violation count"


def test_constraint_decorator_with_metadata():
    metric_set = MetricSet()

    @metric_set.constraint("errors", threshold=0, metadata={"severity": "critical"})
    def errors(x):
        return x

    result = metric_set.compute(0.0)
    assert result.constraints["errors"].metadata == {"severity": "critical"}


def test_decorator_with_computed_value():
    metric_set = MetricSet()

    @metric_set.metric("info")
    def info(x):
        return ComputedValue(value=x * 2, debug={"doubled": True})

    result = metric_set.compute(0.5)
    assert result.metrics["info"].value == pytest.approx(1.0)
    assert result.metrics["info"].debug == {"doubled": True}


def test_docstring_parsing_full():
    """Test parsing a complete Google-style docstring with all sections."""
    metric_set = MetricSet()

    @metric_set.metric("coverage")
    def coverage(x):
        """Percentage of code paths covered by tests.

        Range: 0-100
        Interpretation:
            - 90-100: Excellent
            - 70-90:  Good
            - <70:    Needs improvement
        Notes:
            - Returns 0 for empty input.
        """
        return x

    result = metric_set.compute(0.85)
    help_dict = result.metrics["coverage"].help

    assert help_dict["summary"] == "Percentage of code paths covered by tests."
    assert help_dict["range"] == "0-100"
    assert "90-100: Excellent" in help_dict["interpretation"]
    assert "70-90:  Good" in help_dict["interpretation"]
    assert "<70:    Needs improvement" in help_dict["interpretation"]
    assert "Returns 0 for empty input." in help_dict["notes"]


def test_docstring_parsing_missing_section():
    """Test that missing sections are absent from the dict."""
    metric_set = MetricSet()

    @metric_set.metric("score")
    def score(x):
        """A simple score metric.

        Range: 0-10
        """
        return x

    result = metric_set.compute(5.0)
    help_dict = result.metrics["score"].help

    assert help_dict["summary"] == "A simple score metric."
    assert help_dict["range"] == "0-10"
    assert "interpretation" not in help_dict
    assert "notes" not in help_dict


def test_docstring_parsing_no_docstring():
    """Test that missing docstring results in empty help dict."""
    metric_set = MetricSet()

    @metric_set.metric("no_doc")
    def no_doc(x):
        return x

    result = metric_set.compute(42.0)
    assert result.metrics["no_doc"].help == {}


def test_docstring_parsing_constraint():
    """Test docstring parsing works for constraints too."""
    metric_set = MetricSet()

    @metric_set.constraint("errors", threshold=5)
    def errors(x):
        """Count of errors in the system.

        Range: 0+
        Notes:
            - Lower is better.
        """
        return x

    result = metric_set.compute(2)
    help_dict = result.constraints["errors"].help

    assert help_dict["summary"] == "Count of errors in the system."
    assert help_dict["range"] == "0+"
    assert "Lower is better." in help_dict["notes"]


def test_metric_definition_access():
    """Test accessing metric definition via metric_set.metrics dict."""
    metric_set = MetricSet()

    @metric_set.metric("quality")
    def quality(x):
        """Quality of the system.

        Range: 0-100
        """
        return x

    defn = metric_set.metrics["quality"]
    assert defn.key == "quality"
    assert defn.help["summary"] == "Quality of the system."
    assert defn.help["range"] == "0-100"


def test_constraint_definition_access():
    """Test accessing constraint definition via metric_set.constraints dict."""
    metric_set = MetricSet()

    @metric_set.constraint("violations", threshold=0)
    def violations(x):
        """Constraint violations.

        Notes:
            - Should be zero.
        """
        return x

    defn = metric_set.constraints["violations"]
    assert defn.key == "violations"
    assert "Should be zero." in defn.help["notes"]


def test_definition_not_found():
    """Test that accessing nonexistent definition raises KeyError."""
    metric_set = MetricSet()
    with pytest.raises(KeyError):
        metric_set.metrics["nonexistent"]


def test_duplicate_metric_key_raises():
    metric_set = MetricSet()
    metric_set.add("score", lambda x: x)
    with pytest.raises(ValueError, match="score"):
        metric_set.add("score", lambda x: x * 2)


def test_duplicate_constraint_key_raises():
    metric_set = MetricSet()
    metric_set.add_constraint("errors", lambda x: x, threshold=0)
    with pytest.raises(ValueError, match="errors"):
        metric_set.add_constraint("errors", lambda x: x, threshold=1)


def test_metric_and_result_help_consistent():
    """Test that metric_set.metrics[key].help matches result.metrics[key].help."""
    metric_set = MetricSet()

    @metric_set.metric("performance")
    def performance(x):
        """Performance metric.

        Range: 0-1
        Interpretation:
            - >0.9: Fast
            - <0.1: Slow
        """
        return x

    result = metric_set.compute(0.95)
    metric_set_help = metric_set.metrics["performance"].help
    result_help = result.metrics["performance"].help

    assert metric_set_help == result_help
    assert metric_set_help["summary"] == "Performance metric."
    assert metric_set_help["range"] == "0-1"


def test_docstring_multiline_summary():
    """Test that multiline summaries are joined with spaces."""
    metric_set = MetricSet()

    @metric_set.metric("metric")
    def metric(x):
        """This is a summary that spans
        multiple lines in the docstring.

        Range: 0-1
        """
        return x

    result = metric_set.compute(0.5)
    assert (
        result.metrics["metric"].help["summary"]
        == "This is a summary that spans multiple lines in the docstring."
    )


def test_constraint_higher_is_better_passes():
    metric_set = MetricSet()
    metric_set.add_constraint("accuracy", lambda x: x, threshold=0.8, higher_is_better=True)
    result = metric_set.compute(0.9)
    assert result.constraints["accuracy"].passed is True
    assert result.constraints["accuracy"].higher_is_better is True


def test_constraint_higher_is_better_fails():
    metric_set = MetricSet()
    metric_set.add_constraint("accuracy", lambda x: x, threshold=0.8, higher_is_better=True)
    result = metric_set.compute(0.7)
    assert result.constraints["accuracy"].passed is False


def test_constraint_higher_is_better_exact_threshold():
    metric_set = MetricSet()
    metric_set.add_constraint("accuracy", lambda x: x, threshold=0.8, higher_is_better=True)
    result = metric_set.compute(0.8)
    assert result.constraints["accuracy"].passed is True


def test_constraint_default_lower_is_better():
    metric_set = MetricSet()
    metric_set.add_constraint("errors", lambda x: x, threshold=5.0)
    result = metric_set.compute(3.0)
    assert result.constraints["errors"].passed is True
    assert result.constraints["errors"].higher_is_better is False


def test_constraint_decorator_higher_is_better():
    metric_set = MetricSet()

    @metric_set.constraint("coverage", threshold=0.9, higher_is_better=True)
    def coverage(x):
        return x

    result = metric_set.compute(0.95)
    assert result.constraints["coverage"].passed is True
    assert result.constraints["coverage"].higher_is_better is True

    result2 = metric_set.compute(0.85)
    assert result2.constraints["coverage"].passed is False


def test_metric_name_defaults_to_empty():
    metric_set = MetricSet()
    metric_set.add("score", lambda x: x)
    result = metric_set.compute(0.5)
    assert result.metrics["score"].name == ""


def test_constraint_name_defaults_to_empty():
    metric_set = MetricSet()
    metric_set.add_constraint("errors", lambda x: x, threshold=0)
    result = metric_set.compute(0.0)
    assert result.constraints["errors"].name == ""


def test_metric_name_propagated_via_add():
    metric_set = MetricSet()
    metric_set.add("score", lambda x: x, name="Quality Score")
    result = metric_set.compute(0.5)
    assert result.metrics["score"].name == "Quality Score"


def test_constraint_name_propagated_via_add_constraint():
    metric_set = MetricSet()
    metric_set.add_constraint("errors", lambda x: x, name="Error Count", threshold=0)
    result = metric_set.compute(0.0)
    assert result.constraints["errors"].name == "Error Count"


def test_metric_name_propagated_via_decorator():
    metric_set = MetricSet()

    @metric_set.metric("score", name="Quality Score")
    def score(x):
        return x

    result = metric_set.compute(0.5)
    assert result.metrics["score"].name == "Quality Score"


def test_constraint_name_propagated_via_decorator():
    metric_set = MetricSet()

    @metric_set.constraint("errors", name="Error Count", threshold=0)
    def errors(x):
        return x

    result = metric_set.compute(0.0)
    assert result.constraints["errors"].name == "Error Count"


def test_normalize_propagated_through_compute():
    metric_set = MetricSet()
    normalize_fn = lambda v: v / 10.0
    metric_set.add("latency", lambda x: x, normalize=normalize_fn)
    result = metric_set.compute(5.0)
    assert result.metrics["latency"].normalize is normalize_fn
    assert result.metrics["latency"].value == 5.0


def test_typed_metadata():
    """Test that MetricSet accepts a second type parameter for typed metadata."""

    class MyMeta(TypedDict):
        category: str
        priority: int

    ms: MetricSet[dict, MyMeta] = MetricSet()
    ms.add("test", lambda d: 1.0, metadata=MyMeta(category="perf", priority=1))
    ms.add_constraint(
        "check", lambda d: 0.0, threshold=1.0, metadata=MyMeta(category="safety", priority=2)
    )
    result = ms.compute({})
    assert result.metrics["test"].metadata["category"] == "perf"
    assert result.metrics["test"].metadata["priority"] == 1
    assert result.constraints["check"].metadata["category"] == "safety"


def test_docstring_case_insensitive_sections():
    """Test that section headers are case-insensitive."""
    metric_set = MetricSet()

    @metric_set.metric("test")
    def test(x):
        """Test metric.

        RANGE: 0-100
        """
        return x

    result = metric_set.compute(50)
    assert result.metrics["test"].help["range"] == "0-100"
