"""Unit tests for :class:`~llmner.factories.SchemaRegistry` type factories."""

from __future__ import annotations

import re

from llmner import NERBaseModel, SchemaRegistry

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


def _make_schema(registry: SchemaRegistry, **field_types: type) -> type[NERBaseModel]:
    """Dynamically create a NERBaseModel subclass with the given fields."""
    annotations = {name: ft | None for name, ft in field_types.items()}
    defaults = {name: None for name in field_types}
    ns: dict = {"__annotations__": annotations, **defaults}
    return type("DynSchema", (NERBaseModel,), ns)


def _parse(schema_cls: type[NERBaseModel], data: dict, text: str = "") -> NERBaseModel:
    """Shortcut: validate *data* through safe_parse."""
    result = schema_cls.safe_parse(data, input_text=text)
    assert result is not None
    return result


# ---------------------------------------------------------------------------
# categorical
# ---------------------------------------------------------------------------


class TestCategorical:
    def setup_method(self) -> None:
        self.reg = SchemaRegistry()
        self.Gender = self.reg.categorical(
            "gender", options=["male", "female"], instruction="Gender."
        )
        self.Schema = _make_schema(self.reg, gender=self.Gender)

    def test_valid_value(self) -> None:
        res = _parse(
            self.Schema,
            {"gender": {"value": "male", "evidence": "male"}},
            "He is male.",
        )
        assert res.gender.value == "male"

    def test_case_insensitive(self) -> None:
        res = _parse(self.Schema, {"gender": {"value": "Male", "evidence": None}})
        assert res.gender.value == "male"

    def test_invalid_value_becomes_none(self) -> None:
        res = _parse(self.Schema, {"gender": {"value": "robot", "evidence": None}})
        assert res.gender.value is None

    def test_null_string_becomes_none(self) -> None:
        res = _parse(self.Schema, {"gender": {"value": "null", "evidence": None}})
        assert res.gender.value is None

    def test_registers_rule(self) -> None:
        assert len(self.reg.rules) == 1
        assert "GENDER" in self.reg.rules[0]
        assert "male" in self.reg.rules[0]

    def test_dict_options_with_descriptions(self) -> None:
        reg = SchemaRegistry()
        Status = reg.categorical(
            "status",
            options={"active": "currently working", "inactive": "retired"},
            instruction="Status.",
        )
        Schema = _make_schema(reg, status=Status)
        res = _parse(Schema, {"status": {"value": "active", "evidence": None}})
        assert res.status.value == "active"
        assert "active" in reg.rules[0]
        assert "currently working" in reg.rules[0]

    def test_replacements(self) -> None:
        reg = SchemaRegistry()
        Hair = reg.categorical(
            "hair",
            options=["dark_brown", "black"],
            instruction="Hair colour.",
            replacements={" ": "_"},
        )
        Schema = _make_schema(reg, hair=Hair)
        res = _parse(Schema, {"hair": {"value": "dark brown", "evidence": None}})
        assert res.hair.value == "dark_brown"

    def test_allow_multiple(self) -> None:
        reg = SchemaRegistry()
        Weapon = reg.categorical(
            "weapon",
            options=["knife", "gun", "fists"],
            instruction="Weapon.",
            allow_multiple=True,
        )
        Schema = _make_schema(reg, weapon=Weapon)
        res = _parse(Schema, {"weapon": {"value": "knife and fists", "evidence": None}})
        assert "knife" in res.weapon.value
        assert "fists" in res.weapon.value

    def test_fallback_parser_recovers_value(self) -> None:
        reg = SchemaRegistry()
        Gender = reg.categorical(
            "gender",
            options=["male", "female"],
            instruction="Gender.",
            fallback_parser=lambda ev: "male" if "man" in ev.lower() else None,
        )
        Schema = _make_schema(reg, gender=Gender)
        res = _parse(
            Schema,
            {"gender": {"value": None, "evidence": "the man was tall"}},
            "the man was tall",
        )
        assert res.gender.value == "male"

    def test_fallback_parser_not_called_when_value_present(self) -> None:
        called = False

        def spy(ev: str) -> str | None:
            nonlocal called
            called = True
            return "female"

        reg = SchemaRegistry()
        Gender = reg.categorical(
            "gender",
            options=["male", "female"],
            instruction="Gender.",
            fallback_parser=spy,
        )
        Schema = _make_schema(reg, gender=Gender)
        _parse(Schema, {"gender": {"value": "male", "evidence": "male"}}, "male")
        assert not called

    def test_fallback_parser_returns_none(self) -> None:
        reg = SchemaRegistry()
        Gender = reg.categorical(
            "gender",
            options=["male", "female"],
            instruction="Gender.",
            fallback_parser=lambda ev: None,
        )
        Schema = _make_schema(reg, gender=Gender)
        res = _parse(
            Schema,
            {"gender": {"value": None, "evidence": "some evidence"}},
            "some evidence",
        )
        assert res.gender.value is None


# ---------------------------------------------------------------------------
# int_range
# ---------------------------------------------------------------------------


class TestIntRange:
    def setup_method(self) -> None:
        self.reg = SchemaRegistry()
        self.Age = self.reg.int_range("age", "Age in years.")
        self.Schema = _make_schema(self.reg, age=self.Age)

    def test_single_integer(self) -> None:
        res = _parse(self.Schema, {"age": {"value": "25", "evidence": None}})
        assert res.age.value == "25"

    def test_range(self) -> None:
        res = _parse(self.Schema, {"age": {"value": "25-30", "evidence": None}})
        assert res.age.value == "25-30"

    def test_range_with_spaces(self) -> None:
        res = _parse(self.Schema, {"age": {"value": "25 - 30", "evidence": None}})
        assert res.age.value == "25-30"

    def test_metre_conversion(self) -> None:
        reg = SchemaRegistry()
        Height = reg.int_range("height", "Height in cm.")
        Schema = _make_schema(reg, height=Height)
        res = _parse(Schema, {"height": {"value": "1.75", "evidence": None}})
        assert res.height.value == "175"

    def test_null_string(self) -> None:
        res = _parse(self.Schema, {"age": {"value": "null", "evidence": None}})
        assert res.age.value is None

    def test_non_numeric_becomes_none(self) -> None:
        res = _parse(self.Schema, {"age": {"value": "unknown", "evidence": None}})
        assert res.age.value is None

    def test_registers_rule(self) -> None:
        assert len(self.reg.rules) == 1
        assert "AGE" in self.reg.rules[0]

    def test_fallback_parser_recovers_value(self) -> None:
        reg = SchemaRegistry()
        Age = reg.int_range(
            "age",
            "Age in years.",
            fallback_parser=lambda ev: (
                re.search(r"\d+", ev).group() if re.search(r"\d+", ev) else None
            ),
        )
        Schema = _make_schema(reg, age=Age)
        res = _parse(
            Schema,
            {"age": {"value": None, "evidence": "aged 34"}},
            "aged 34",
        )
        assert res.age.value == "34"


# ---------------------------------------------------------------------------
# generic
# ---------------------------------------------------------------------------


class TestGeneric:
    def test_plain_string(self) -> None:
        reg = SchemaRegistry()
        Addr = reg.generic("address", "Full address.")
        Schema = _make_schema(reg, address=Addr)
        res = _parse(Schema, {"address": {"value": "123 Main St", "evidence": None}})
        assert res.address.value == "123 Main St"

    def test_null_becomes_none(self) -> None:
        reg = SchemaRegistry()
        Addr = reg.generic("address", "Full address.")
        Schema = _make_schema(reg, address=Addr)
        res = _parse(Schema, {"address": {"value": "null", "evidence": None}})
        assert res.address.value is None

    def test_custom_python_type(self) -> None:
        reg = SchemaRegistry()
        Count = reg.generic("count", "Number of items.", python_type=int)
        Schema = _make_schema(reg, count=Count)
        res = _parse(Schema, {"count": {"value": "42", "evidence": None}})
        # python_type validates but value is always stored as str
        assert res.count.value == "42"

    def test_custom_python_type_invalid(self) -> None:
        reg = SchemaRegistry()
        Count = reg.generic("count", "Number of items.", python_type=int)
        Schema = _make_schema(reg, count=Count)
        res = _parse(Schema, {"count": {"value": "not-a-number", "evidence": None}})
        assert res.count.value is None

    def test_fallback_parser_recovers_value(self) -> None:
        reg = SchemaRegistry()
        Name = reg.generic(
            "name",
            "Full name.",
            fallback_parser=lambda ev: (
                ev.split("called ")[-1] if "called" in ev else None
            ),
        )
        Schema = _make_schema(reg, name=Name)
        res = _parse(
            Schema,
            {"name": {"value": None, "evidence": "person called John"}},
            "person called John",
        )
        assert res.name.value == "John"


# ---------------------------------------------------------------------------
# datetime_format
# ---------------------------------------------------------------------------


class TestDatetimeFormat:
    def setup_method(self) -> None:
        self.reg = SchemaRegistry()
        self.Dt = self.reg.datetime_format("datetime", "Date and time.")
        self.Schema = _make_schema(self.reg, datetime=self.Dt)

    def test_iso_format(self) -> None:
        res = _parse(
            self.Schema,
            {"datetime": {"value": "2024-03-15 14:30:00", "evidence": None}},
        )
        assert res.datetime.value == "2024-03-15 14:30:00"

    def test_short_iso(self) -> None:
        res = _parse(
            self.Schema, {"datetime": {"value": "2024-03-15 14:30", "evidence": None}}
        )
        assert res.datetime.value == "2024-03-15 14:30:00"

    def test_date_only(self) -> None:
        res = _parse(
            self.Schema, {"datetime": {"value": "2024-03-15", "evidence": None}}
        )
        assert res.datetime.value == "2024-03-15 00:00:00"

    def test_european_format(self) -> None:
        res = _parse(
            self.Schema, {"datetime": {"value": "15/03/2024", "evidence": None}}
        )
        assert res.datetime.value == "2024-03-15 00:00:00"

    def test_invalid_date_becomes_none(self) -> None:
        res = _parse(
            self.Schema, {"datetime": {"value": "not-a-date", "evidence": None}}
        )
        assert res.datetime.value is None

    def test_null_becomes_none(self) -> None:
        res = _parse(self.Schema, {"datetime": {"value": "null", "evidence": None}})
        assert res.datetime.value is None

    def test_extra_formats(self) -> None:
        reg = SchemaRegistry()
        Dt = reg.datetime_format(
            "datetime",
            "Date and time.",
            extra_formats=("%B %d, %Y",),
        )
        Schema = _make_schema(reg, datetime=Dt)
        res = _parse(
            Schema, {"datetime": {"value": "March 15, 2024", "evidence": None}}
        )
        assert res.datetime.value == "2024-03-15 00:00:00"

    def test_fallback_parser_recovers_value(self) -> None:
        reg = SchemaRegistry()
        Dt = reg.datetime_format(
            "datetime",
            "Date and time.",
            fallback_parser=lambda ev: ev.strip() if re.search(r"\d{4}", ev) else None,
        )
        Schema = _make_schema(reg, datetime=Dt)
        res = _parse(
            Schema,
            {"datetime": {"value": None, "evidence": "2024-03-15"}},
            "2024-03-15",
        )
        assert res.datetime.value == "2024-03-15 00:00:00"


# ---------------------------------------------------------------------------
# Evidence validation
# ---------------------------------------------------------------------------


class TestEvidenceValidation:
    def test_valid_evidence_kept(self) -> None:
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.")
        Schema = _make_schema(reg, name=Name)
        text = "The victim was John Smith, aged 34."
        res = _parse(
            Schema, {"name": {"value": "John Smith", "evidence": "John Smith"}}, text
        )
        assert res.name.evidence == "John Smith"

    def test_bad_evidence_replaced_by_value_in_text(self) -> None:
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.")
        Schema = _make_schema(reg, name=Name)
        text = "The victim was John Smith, aged 34."
        res = _parse(
            Schema, {"name": {"value": "John Smith", "evidence": "Jane Doe"}}, text
        )
        # Bad evidence is discarded; value found in text becomes evidence
        assert res.name.evidence == "John Smith"

    def test_evidence_case_insensitive(self) -> None:
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.")
        Schema = _make_schema(reg, name=Name)
        text = "The victim was JOHN SMITH, aged 34."
        res = _parse(
            Schema, {"name": {"value": "John Smith", "evidence": "john smith"}}, text
        )
        # Value found in text (case-insensitive) takes priority over model evidence
        assert res.name.evidence == "John Smith"

    def test_evidence_whitespace_normalised(self) -> None:
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.")
        Schema = _make_schema(reg, name=Name)
        text = "The victim was John\n  Smith, aged 34."
        res = _parse(
            Schema, {"name": {"value": "John Smith", "evidence": "John Smith"}}, text
        )
        assert res.name.evidence == "John Smith"


# ---------------------------------------------------------------------------
# Evidence resolution (_resolve_evidence integration)
# ---------------------------------------------------------------------------


class TestEvidenceResolution:
    """Tests for the automatic evidence-resolution pipeline.

    _resolve_evidence applies these priority rules:
    1. Full value found in text → value becomes evidence (supersedes model evidence)
    2. Value NOT in text but model evidence exists → keep model evidence
    3. Value NOT in text, no model evidence → partial token-prefix fallback
    4. Nothing matches → None
    """

    def test_rule1_value_in_text_supersedes_model_evidence(self) -> None:
        """When value is found verbatim in text, it replaces model evidence."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.")
        Schema = _make_schema(reg, name=Name)
        text = "The victim was John Smith, aged 34."
        res = _parse(
            Schema,
            {"name": {"value": "John Smith", "evidence": "victim was John Smith"}},
            text,
        )
        assert res.name.evidence == "John Smith"

    def test_rule2_value_not_in_text_keeps_model_evidence(self) -> None:
        """When value is NOT in text, valid model evidence is preserved."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.")
        Schema = _make_schema(reg, name=Name)
        text = "The suspect was described as tall and dark-haired."
        res = _parse(
            Schema,
            {"name": {"value": "Unknown Male", "evidence": "tall and dark-haired"}},
            text,
        )
        assert res.name.value == "Unknown Male"
        assert res.name.evidence == "tall and dark-haired"

    def test_rule3_partial_prefix_fallback_when_no_evidence(self) -> None:
        """When no model evidence exists, a partial value prefix is used."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.")
        Schema = _make_schema(reg, name=Name)
        # Text contains "John Smith" but value has extra tokens not in text
        text = "The victim was John Smith, aged 34."
        res = _parse(
            Schema,
            {"name": {"value": "John Smith Jr III", "evidence": None}},
            text,
        )
        # Partial prefix "John Smith Jr" won't be in text, but "John Smith" will
        assert res.name.evidence == "John Smith"

    def test_rule4_no_match_returns_none(self) -> None:
        """When neither value nor partial prefix is in text, evidence is None."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.")
        Schema = _make_schema(reg, name=Name)
        text = "No relevant information found."
        res = _parse(
            Schema,
            {"name": {"value": "XY", "evidence": None}},
            text,
        )
        # Value too short (< 3 chars) and no model evidence
        assert res.name.evidence is None

    def test_short_value_below_min_length_no_auto_evidence(self) -> None:
        """Values shorter than _MIN_EVIDENCE_LEN don't trigger auto-evidence."""
        reg = SchemaRegistry()
        Cat = reg.categorical("x", ["ab"], "Test.")
        Schema = _make_schema(reg, x=Cat)
        text = "Something ab here."
        res = _parse(Schema, {"x": {"value": "ab", "evidence": None}}, text)
        # "ab" is only 2 chars, below _MIN_EVIDENCE_LEN=3
        assert res.name.evidence is None if hasattr(res, "name") else True
        assert res.x.evidence is None

    def test_resolve_evidence_without_input_text(self) -> None:
        """Without input_text in context, evidence is returned as-is."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.")
        Schema = _make_schema(reg, name=Name)
        # Parse without providing source text
        res = _parse(Schema, {"name": {"value": "John", "evidence": "some quote"}}, "")
        assert res.name.evidence == "some quote"

    def test_resolve_evidence_categorical_allow_multiple(self) -> None:
        """Evidence resolution works with allow_multiple categorical fields."""
        reg = SchemaRegistry()
        Colors = reg.categorical(
            "colors", ["red", "blue", "green"], "Colors.", allow_multiple=True
        )
        Schema = _make_schema(reg, colors=Colors)
        text = "The car was red and blue."
        res = _parse(Schema, {"colors": {"value": "red, blue", "evidence": None}}, text)
        assert res.colors.value == "red, blue"
        # "red, blue" won't be literally in text, but partial prefix or None
        # The comma-joined value "red, blue" is not verbatim in "red and blue"
        # so evidence may be None (no partial prefix >= 2 tokens matches)

    def test_resolve_evidence_int_range(self) -> None:
        """Evidence resolution works with int_range fields."""
        reg = SchemaRegistry()
        Age = reg.int_range("age", "Age.")
        Schema = _make_schema(reg, age=Age)
        text = "The suspect is approximately 125 years old."
        res = _parse(Schema, {"age": {"value": "125", "evidence": None}}, text)
        assert res.age.value == "125"
        assert res.age.evidence == "125"

    def test_resolve_evidence_datetime(self) -> None:
        """Evidence resolution works with datetime fields."""
        reg = SchemaRegistry()
        Date = reg.datetime_format("date", "Date.")
        Schema = _make_schema(reg, date=Date)
        text = "The incident occurred on 2024-03-15 at noon."
        res = _parse(Schema, {"date": {"value": "2024-03-15", "evidence": None}}, text)
        assert res.date.value == "2024-03-15 00:00:00"
        # Formatted value "2024-03-15 00:00:00" is NOT in text,
        # but evidence may still be None since formatted != original


# ---------------------------------------------------------------------------
# Registry rules accumulation
# ---------------------------------------------------------------------------


class TestRegistryRules:
    def test_rules_accumulate_in_order(self) -> None:
        reg = SchemaRegistry()
        reg.categorical("a", ["x"], "First.")
        reg.int_range("b", "Second.")
        reg.generic("c", "Third.")
        reg.datetime_format("d", "Fourth.")
        rules = reg.rules
        assert len(rules) == 4
        assert "A:" in rules[0]
        assert "B:" in rules[1]
        assert "C:" in rules[2]
        assert "D:" in rules[3]

    def test_rules_returns_copy(self) -> None:
        reg = SchemaRegistry()
        reg.generic("x", "Test.")
        rules = reg.rules
        rules.append("extra")
        assert len(reg.rules) == 1


# ---------------------------------------------------------------------------
# require_evidence
# ---------------------------------------------------------------------------


class TestRequireEvidence:
    """Tests for the per-field evidence_required feature.

    When evidence_required=True is passed to a factory, values without
    evidence in the source text are discarded — ensuring every returned
    value is grounded.  The default is evidence_required=False.
    """

    def test_default_evidence_required_is_false(self) -> None:
        """Default behaviour: values are kept even without evidence."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.")
        Schema = _make_schema(reg, name=Name)
        text = "No relevant information here."
        res = _parse(
            Schema,
            {"name": {"value": "John Smith", "evidence": None}},
            text,
        )
        # Without evidence in text, value is kept because default is False
        assert res.name.value == "John Smith"

    def test_value_without_evidence_discarded_when_required(self) -> None:
        """A value that cannot be found in the text and has no model
        evidence is discarded when evidence_required=True."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.", evidence_required=True)
        Schema = _make_schema(reg, name=Name)
        text = "No relevant information here."
        res = _parse(
            Schema,
            {"name": {"value": "John Smith", "evidence": None}},
            text,
        )
        assert res.name.value is None
        assert res.name.evidence is None

    def test_value_kept_when_evidence_exists(self) -> None:
        """A value WITH evidence is kept regardless of evidence_required."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.", evidence_required=True)
        Schema = _make_schema(reg, name=Name)
        text = "The victim was John Smith, aged 34."
        res = _parse(
            Schema,
            {"name": {"value": "John Smith", "evidence": "John Smith"}},
            text,
        )
        assert res.name.value == "John Smith"
        assert res.name.evidence == "John Smith"

    def test_value_kept_when_found_in_text(self) -> None:
        """Even without model evidence, a value found verbatim in the text
        gets auto-resolved evidence and is kept."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.", evidence_required=True)
        Schema = _make_schema(reg, name=Name)
        text = "The victim was John Smith, aged 34."
        res = _parse(
            Schema,
            {"name": {"value": "John Smith", "evidence": None}},
            text,
        )
        assert res.name.value == "John Smith"
        assert res.name.evidence == "John Smith"

    def test_not_required_keeps_value_without_evidence(self) -> None:
        """With evidence_required=False (default), values are kept even without evidence."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.")  # evidence_required=False is the default
        Schema = _make_schema(reg, name=Name)
        text = "No relevant information here."
        res = _parse(
            Schema,
            {"name": {"value": "John Smith", "evidence": None}},
            text,
        )
        assert res.name.value == "John Smith"
        assert res.name.evidence is None

    def test_per_field_evidence_required_true(self) -> None:
        """Per-field evidence_required=True discards value without evidence."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.", evidence_required=True)
        Schema = _make_schema(reg, name=Name)
        text = "No relevant information here."
        res = _parse(
            Schema,
            {"name": {"value": "John Smith", "evidence": None}},
            text,
        )
        assert res.name.value is None

    def test_per_field_evidence_required_false(self) -> None:
        """Per-field evidence_required=False keeps value without evidence."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.", evidence_required=False)
        Schema = _make_schema(reg, name=Name)
        text = "No relevant information here."
        res = _parse(
            Schema,
            {"name": {"value": "John Smith", "evidence": None}},
            text,
        )
        assert res.name.value == "John Smith"

    def test_not_enforced_without_input_text(self) -> None:
        """Without input_text, evidence_required is not enforced — evidence
        verification was never attempted, so discarding would be incorrect."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.", evidence_required=True)
        Schema = _make_schema(reg, name=Name)
        res = _parse(
            Schema,
            {"name": {"value": "John Smith", "evidence": None}},
            "",
        )
        assert res.name.value == "John Smith"

    def test_categorical_evidence_required(self) -> None:
        reg = SchemaRegistry()
        Gender = reg.categorical(
            "gender", ["male", "female"], "Gender.", evidence_required=True
        )
        Schema = _make_schema(reg, gender=Gender)
        text = "No gender info here."
        res = _parse(
            Schema,
            {"gender": {"value": "male", "evidence": None}},
            text,
        )
        assert res.gender.value is None

    def test_int_range_evidence_required(self) -> None:
        reg = SchemaRegistry()
        Age = reg.int_range("age", "Age.", evidence_required=True)
        Schema = _make_schema(reg, age=Age)
        text = "No age info here."
        res = _parse(
            Schema,
            {"age": {"value": "25", "evidence": None}},
            text,
        )
        assert res.age.value is None

    def test_datetime_evidence_required(self) -> None:
        reg = SchemaRegistry()
        Date = reg.datetime_format("date", "Date.", evidence_required=True)
        Schema = _make_schema(reg, date=Date)
        text = "No date info here."
        res = _parse(
            Schema,
            {"date": {"value": "2024-03-15", "evidence": None}},
            text,
        )
        assert res.date.value is None

    def test_null_value_unaffected(self) -> None:
        """A null value stays null (evidence_required doesn't interfere)."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.", evidence_required=True)
        Schema = _make_schema(reg, name=Name)
        text = "Some text."
        res = _parse(
            Schema,
            {"name": {"value": None, "evidence": None}},
            text,
        )
        assert res.name.value is None


# ---------------------------------------------------------------------------
# Regression: partial-prefix single-token fallback
# ---------------------------------------------------------------------------


class TestPartialPrefixSingleToken:
    """Regression tests for single-token prefix evidence resolution.

    The partial-prefix fallback (Rule 3) must try single-token prefixes so
    that datetime values like "2024-01-01 08:13:00" can use the first token
    "2024-01-01" as evidence when it appears in the source text.
    """

    def test_datetime_single_token_prefix(self) -> None:
        """First token of a formatted datetime should serve as evidence."""
        reg = SchemaRegistry()
        Dt = reg.datetime_format("date", "Date.")
        Schema = _make_schema(reg, date=Dt)
        text = "Sobre las 08:13 horas del día 2024-01-01."
        res = _parse(
            Schema,
            {"date": {"value": "2024-01-01 08:13", "evidence": None}},
            text,
        )
        assert res.date.value == "2024-01-01 08:13:00"
        # "2024-01-01 08:13:00" is NOT verbatim in text, but "2024-01-01"
        # (single-token prefix) IS — it should be used as evidence.
        assert res.date.evidence is not None
        assert "2024-01-01" in res.date.evidence

    def test_generic_two_token_value_first_token_evidence(self) -> None:
        """A 2-token value should try the first token as partial evidence."""
        reg = SchemaRegistry()
        Name = reg.generic("name", "Name.")
        Schema = _make_schema(reg, name=Name)
        text = "The suspect called himself Alejandro during the incident."
        res = _parse(
            Schema,
            {"name": {"value": "Alejandro García", "evidence": None}},
            text,
        )
        # "Alejandro García" is not in text, but "Alejandro" (single token) is
        assert res.name.value == "Alejandro García"
        assert res.name.evidence == "Alejandro"


# ---------------------------------------------------------------------------
# Regression: int_range metre conversion for ranges
# ---------------------------------------------------------------------------


class TestIntRangeMetreConversion:
    """Regression tests for metre-to-cm conversion in int_range.

    The conversion must handle both single values ("1.75" → "175") and
    ranges ("1,65-1,80" → "165-180") without truncating the second number.
    """

    def test_single_metre_value(self) -> None:
        reg = SchemaRegistry()
        H = reg.int_range("height", "Height in cm.")
        Schema = _make_schema(reg, height=H)
        res = _parse(Schema, {"height": {"value": "1.75", "evidence": None}})
        assert res.height.value == "175"

    def test_metre_range_dash(self) -> None:
        """'1,65-1,80' must produce '165-180', not '165'."""
        reg = SchemaRegistry()
        H = reg.int_range("height", "Height in cm.")
        Schema = _make_schema(reg, height=H)
        res = _parse(Schema, {"height": {"value": "1,65-1,80", "evidence": None}})
        assert res.height.value == "165-180"

    def test_metre_range_y(self) -> None:
        """'1,65 y 1,80' must produce '165-180'."""
        reg = SchemaRegistry()
        H = reg.int_range("height", "Height in cm.")
        Schema = _make_schema(reg, height=H)
        res = _parse(Schema, {"height": {"value": "1,65 y 1,80", "evidence": None}})
        assert res.height.value == "165-180"

    def test_metre_single_with_unit(self) -> None:
        """'1.75m' should convert to '175'."""
        reg = SchemaRegistry()
        H = reg.int_range("height", "Height in cm.")
        Schema = _make_schema(reg, height=H)
        res = _parse(Schema, {"height": {"value": "1.75m", "evidence": None}})
        assert res.height.value == "175"

    def test_cm_range_unaffected(self) -> None:
        """Already-converted cm ranges should pass through unchanged."""
        reg = SchemaRegistry()
        H = reg.int_range("height", "Height in cm.")
        Schema = _make_schema(reg, height=H)
        res = _parse(Schema, {"height": {"value": "165-180", "evidence": None}})
        assert res.height.value == "165-180"

    def test_age_range_unaffected(self) -> None:
        """Age ranges should not be misinterpreted as metres."""
        reg = SchemaRegistry()
        Age = reg.int_range("age", "Age.")
        Schema = _make_schema(reg, age=Age)
        res = _parse(Schema, {"age": {"value": "25-30", "evidence": None}})
        assert res.age.value == "25-30"


class TestRawValueEvidence:
    """Tests for raw_value evidence resolution.

    Factories that transform values (int_range metre→cm, datetime_format
    format normalisation) must pass the pre-transformation string so that
    evidence can still be anchored in the source text.
    """

    def test_int_range_metre_raw_value_evidence(self) -> None:
        """'1.75' (metres) is transformed to '175' (cm).  The raw value
        '1.75' should be found in the source text as evidence."""
        reg = SchemaRegistry()
        H = reg.int_range("height", "Height in cm.")
        Schema = _make_schema(reg, height=H)
        text = "El sospechoso mide 1.75 metros."
        res = _parse(
            Schema, {"height": {"value": "1.75", "evidence": None}}, text
        )
        assert res.height.value == "175"
        assert res.height.evidence == "1.75"

    def test_int_range_metre_evidence_required_kept(self) -> None:
        """With evidence_required=True, metre→cm values must not be
        discarded when the raw value appears in the text."""
        reg = SchemaRegistry()
        H = reg.int_range("height", "Height in cm.", evidence_required=True)
        Schema = _make_schema(reg, height=H)
        text = "Mide 1.75 metros de estatura."
        res = _parse(
            Schema, {"height": {"value": "1.75", "evidence": None}}, text
        )
        assert res.height.value == "175"
        assert res.height.evidence == "1.75"

    def test_datetime_raw_value_evidence(self) -> None:
        """'15/03/2024' is normalised to '2024-03-15 00:00:00'.  The raw
        date string should anchor evidence in the text."""
        reg = SchemaRegistry()
        D = reg.datetime_format("date", "Date.")
        Schema = _make_schema(reg, date=D)
        text = "El incidente ocurrió el 15/03/2024."
        res = _parse(
            Schema, {"date": {"value": "15/03/2024", "evidence": None}}, text
        )
        assert res.date.value == "2024-03-15 00:00:00"
        assert res.date.evidence == "15/03/2024"

    def test_datetime_evidence_required_kept(self) -> None:
        """With evidence_required=True, normalised dates must not be
        discarded when the raw date appears in the text."""
        reg = SchemaRegistry()
        D = reg.datetime_format("date", "Date.", evidence_required=True)
        Schema = _make_schema(reg, date=D)
        text = "Fecha: 15/03/2024 por la mañana."
        res = _parse(
            Schema, {"date": {"value": "15/03/2024", "evidence": None}}, text
        )
        assert res.date.value == "2024-03-15 00:00:00"
        assert res.date.evidence == "15/03/2024"

    def test_int_range_no_false_positive_long_string(self) -> None:
        """A long string starting with '1.75' should not be incorrectly
        truncated by the metre conversion regex."""
        reg = SchemaRegistry()
        H = reg.int_range("height", "Height in cm.")
        Schema = _make_schema(reg, height=H)
        # "1.75 metros de altura con zapatos" is not a valid metre notation
        # — the fullmatch regex should not match it
        res = _parse(
            Schema,
            {"height": {"value": "1.75 metros de altura con zapatos", "evidence": None}},
        )
        # Should parse as integer extraction, not metre conversion
        assert res.height.value is not None


# ---------------------------------------------------------------------------
# Descored evidence resolution (underscore → space matching)
# ---------------------------------------------------------------------------


class TestDescoredEvidence:
    """Tests for underscore-to-space evidence resolution.

    Values like ``"objeto_punzante"`` should match ``"objeto punzante"`` in
    the source text and return the space-separated form as evidence.
    """

    def test_categorical_underscore_value_finds_space_evidence(self) -> None:
        """A categorical value with underscores should match its spaced form."""
        reg = SchemaRegistry()
        Weapon = reg.categorical(
            "weapon",
            options=["objeto_punzante", "arma_blanca"],
            instruction="Weapon.",
        )
        Schema = _make_schema(reg, weapon=Weapon)
        text = "El agresor portaba un objeto punzante."
        res = _parse(
            Schema,
            {"weapon": {"value": "objeto_punzante", "evidence": None}},
            text,
        )
        assert res.weapon.value == "objeto_punzante"
        assert res.weapon.evidence == "objeto punzante"

    def test_categorical_underscore_value_with_bad_model_evidence(self) -> None:
        """When model evidence is invalid, descored value should be used."""
        reg = SchemaRegistry()
        Cat = reg.categorical(
            "cat",
            options=["fuerza_física"],
            instruction="Category.",
        )
        Schema = _make_schema(reg, cat=Cat)
        text = "Utilizó fuerza física para reducir a la víctima."
        res = _parse(
            Schema,
            {"cat": {"value": "fuerza_física", "evidence": "used force"}},
            text,
        )
        assert res.cat.value == "fuerza_física"
        assert res.cat.evidence == "fuerza física"

    def test_no_underscore_value_unaffected(self) -> None:
        """Values without underscores are not affected by descoring."""
        reg = SchemaRegistry()
        Color = reg.categorical(
            "color",
            options=["red", "blue"],
            instruction="Color.",
        )
        Schema = _make_schema(reg, color=Color)
        text = "The car was red."
        res = _parse(
            Schema,
            {"color": {"value": "red", "evidence": None}},
            text,
        )
        assert res.color.value == "red"
        assert res.color.evidence == "red"

    def test_underscore_value_exact_match_takes_priority(self) -> None:
        """If the underscored form is literally in the text, prefer it."""
        reg = SchemaRegistry()
        Code = reg.generic("code", "Code.")
        Schema = _make_schema(reg, code=Code)
        text = "The code was objeto_punzante in the report."
        res = _parse(
            Schema,
            {"code": {"value": "objeto_punzante", "evidence": None}},
            text,
        )
        assert res.code.evidence == "objeto_punzante"

    def test_allow_multiple_underscore_partial_prefix(self) -> None:
        """Descored partial prefix should work for multi-value categoricals."""
        reg = SchemaRegistry()
        Items = reg.categorical(
            "items",
            options=["arma_blanca", "fuerza_física"],
            instruction="Items.",
            allow_multiple=True,
        )
        Schema = _make_schema(reg, items=Items)
        text = "Usó arma blanca y fuerza física."
        res = _parse(
            Schema,
            {"items": {"value": "arma_blanca, fuerza_física", "evidence": None}},
            text,
        )
        assert res.items.value == "arma_blanca, fuerza_física"
        # The combined "arma blanca, fuerza física" won't be verbatim,
        # but partial prefix "arma blanca" (descored) should be found
        assert res.items.evidence is not None

    def test_evidence_required_with_descored_match(self) -> None:
        """evidence_required=True should accept descored evidence."""
        reg = SchemaRegistry()
        Cat = reg.categorical(
            "cat",
            options=["objeto_punzante"],
            instruction="Category.",
            evidence_required=True,
        )
        Schema = _make_schema(reg, cat=Cat)
        text = "Llevaba un objeto punzante."
        res = _parse(
            Schema,
            {"cat": {"value": "objeto_punzante", "evidence": None}},
            text,
        )
        assert res.cat.value == "objeto_punzante"
        assert res.cat.evidence == "objeto punzante"

    def test_descored_not_in_text_returns_none(self) -> None:
        """When neither underscored nor spaced form is in text, return None."""
        reg = SchemaRegistry()
        Cat = reg.categorical(
            "cat",
            options=["objeto_punzante"],
            instruction="Category.",
            evidence_required=True,
        )
        Schema = _make_schema(reg, cat=Cat)
        text = "No hay información relevante."
        res = _parse(
            Schema,
            {"cat": {"value": "objeto_punzante", "evidence": None}},
            text,
        )
        assert res.cat.value is None
        assert res.cat.evidence is None
