"""
Comprehensive coverage tests for the constraints package.

Targets gaps not covered by existing test suites:
- Constraint dataclass edge cases (equality, hashing, mutable defaults, exotic value types)
- to_search_terms with non-string values for specialized branches
- is_critical boundary across every non-NAME_PATTERN type
- ConstraintAnalyzer parsing edge cases (colons in values, extra noise lines,
  think-tag wrapping around constraint blocks, weight edge values)
- Package re-exports from __init__
"""

import pytest
from unittest.mock import MagicMock

from local_deep_research.advanced_search_system.constraints.base_constraint import (
    Constraint,
    ConstraintType,
)
from local_deep_research.advanced_search_system.constraints.constraint_analyzer import (
    ConstraintAnalyzer,
)
from local_deep_research.advanced_search_system.constraints import (
    Constraint as ReexportedConstraint,
    ConstraintType as ReexportedConstraintType,
    ConstraintAnalyzer as ReexportedConstraintAnalyzer,
)


# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------


def _analyzer():
    """Create a ConstraintAnalyzer with a mock model."""
    a = ConstraintAnalyzer.__new__(ConstraintAnalyzer)
    a.model = MagicMock()
    return a


def _mock_response(analyzer, content: str):
    analyzer.model.invoke.return_value = MagicMock(content=content)


def _make(type_=ConstraintType.PROPERTY, value="v", weight=1.0, **kw):
    return Constraint(
        id=kw.pop("id", "c1"),
        type=type_,
        description=kw.pop("description", "desc"),
        value=value,
        weight=weight,
        **kw,
    )


# ===================================================================
# Package __init__ re-exports
# ===================================================================


class TestPackageReexports:
    def test_constraint_reexported(self):
        assert ReexportedConstraint is Constraint

    def test_constraint_type_reexported(self):
        assert ReexportedConstraintType is ConstraintType

    def test_constraint_analyzer_reexported(self):
        assert ReexportedConstraintAnalyzer is ConstraintAnalyzer


# ===================================================================
# ConstraintType enum — identity and iteration
# ===================================================================


class TestConstraintTypeIteration:
    def test_all_values_are_strings(self):
        for ct in ConstraintType:
            assert isinstance(ct.value, str)

    def test_values_are_lowercase(self):
        for ct in ConstraintType:
            assert ct.value == ct.value.lower()

    def test_enum_members_are_unique(self):
        values = [ct.value for ct in ConstraintType]
        assert len(values) == len(set(values))


# ===================================================================
# Constraint dataclass — edge cases
# ===================================================================


class TestConstraintDataclassEdgeCases:
    def test_equality_same_fields(self):
        a = _make()
        b = _make()
        assert a == b

    def test_inequality_different_id(self):
        a = _make(id="c1")
        b = _make(id="c2")
        assert a != b

    def test_metadata_default_not_shared_between_instances(self):
        """Each instance should get its own empty dict, not a shared one."""
        a = _make()
        b = _make()
        a.metadata["x"] = 1
        assert "x" not in b.metadata

    def test_value_can_be_list(self):
        c = _make(value=["a", "b"])
        assert c.value == ["a", "b"]

    def test_value_can_be_dict(self):
        c = _make(value={"key": 42})
        assert c.value == {"key": 42}

    def test_value_can_be_none(self):
        c = _make(value=None)
        assert c.value is None

    def test_value_can_be_bool(self):
        c = _make(value=True)
        assert c.value is True

    def test_weight_zero(self):
        c = _make(weight=0.0)
        assert c.weight == 0.0

    def test_weight_negative(self):
        c = _make(weight=-0.5)
        assert c.weight == -0.5

    def test_description_with_unicode(self):
        c = _make(description="Formed during Würm glaciation")
        assert "Würm" in c.description


# ===================================================================
# to_search_terms — non-string values in specialized branches
# ===================================================================


class TestToSearchTermsEdgeCases:
    def test_property_with_numeric_value(self):
        c = _make(type_=ConstraintType.PROPERTY, value=42)
        # PROPERTY branch returns self.value directly (an int)
        assert c.to_search_terms() == 42

    def test_name_pattern_with_numeric_value(self):
        c = _make(type_=ConstraintType.NAME_PATTERN, value=123)
        result = c.to_search_terms()
        assert "123" in result
        assert "name trail mountain" in result

    def test_event_with_numeric_value(self):
        c = _make(type_=ConstraintType.EVENT, value=2020)
        result = c.to_search_terms()
        assert "2020" in result
        assert "accident incident" in result

    def test_statistic_with_numeric_value(self):
        c = _make(type_=ConstraintType.STATISTIC, value=3.14)
        result = c.to_search_terms()
        assert "3.14" in result
        assert "statistics data" in result

    def test_fallback_branch_for_comparison(self):
        c = _make(type_=ConstraintType.COMPARISON, value=99)
        assert c.to_search_terms() == "99"

    def test_fallback_branch_for_existence(self):
        c = _make(type_=ConstraintType.EXISTENCE, value="has feature")
        assert c.to_search_terms() == "has feature"

    def test_empty_string_value_property(self):
        c = _make(type_=ConstraintType.PROPERTY, value="")
        assert c.to_search_terms() == ""

    def test_empty_string_value_name_pattern(self):
        c = _make(type_=ConstraintType.NAME_PATTERN, value="")
        assert c.to_search_terms() == " name trail mountain"


# ===================================================================
# is_critical — boundary for every non-NAME_PATTERN type
# ===================================================================


class TestIsCriticalAllTypes:
    """Verify is_critical at the 0.8 boundary for every non-NAME_PATTERN type."""

    @pytest.mark.parametrize(
        "ctype",
        [
            ConstraintType.PROPERTY,
            ConstraintType.EVENT,
            ConstraintType.STATISTIC,
            ConstraintType.TEMPORAL,
            ConstraintType.LOCATION,
            ConstraintType.COMPARISON,
            ConstraintType.EXISTENCE,
        ],
    )
    def test_weight_0_8_not_critical(self, ctype):
        c = _make(type_=ctype, weight=0.8)
        assert c.is_critical() is False

    @pytest.mark.parametrize(
        "ctype",
        [
            ConstraintType.PROPERTY,
            ConstraintType.EVENT,
            ConstraintType.STATISTIC,
            ConstraintType.TEMPORAL,
            ConstraintType.LOCATION,
            ConstraintType.COMPARISON,
            ConstraintType.EXISTENCE,
        ],
    )
    def test_weight_0_81_critical(self, ctype):
        c = _make(type_=ctype, weight=0.81)
        assert c.is_critical() is True

    def test_name_pattern_with_weight_zero_still_critical(self):
        c = _make(type_=ConstraintType.NAME_PATTERN, weight=0.0)
        assert c.is_critical() is True

    def test_default_weight_1_0_is_critical(self):
        c = _make(type_=ConstraintType.PROPERTY)
        assert c.is_critical() is True


# ===================================================================
# ConstraintAnalyzer._parse_constraint_type — extra edge cases
# ===================================================================


class TestParseConstraintTypeEdges:
    def test_whitespace_in_type_string(self):
        a = _analyzer()
        # The code does .lower() but NOT .strip() — spaces would cause fallback
        result = a._parse_constraint_type(" property ")
        assert (
            result == ConstraintType.PROPERTY
        )  # Falls to default since " property " != "property"

    def test_all_caps_name_pattern(self):
        a = _analyzer()
        assert (
            a._parse_constraint_type("NAME_PATTERN")
            == ConstraintType.NAME_PATTERN
        )

    def test_numeric_string_defaults_to_property(self):
        a = _analyzer()
        assert a._parse_constraint_type("42") == ConstraintType.PROPERTY


# ===================================================================
# ConstraintAnalyzer._parse_weight — extra edge cases
# ===================================================================


class TestParseWeightEdges:
    def test_zero_int(self):
        a = _analyzer()
        assert a._parse_weight(0) == 0.0

    def test_zero_float(self):
        a = _analyzer()
        assert a._parse_weight(0.0) == 0.0

    def test_negative_float(self):
        a = _analyzer()
        assert a._parse_weight(-0.5) == -0.5

    def test_large_float(self):
        a = _analyzer()
        assert a._parse_weight(100.0) == 100.0

    def test_string_integer_no_decimal(self):
        a = _analyzer()
        assert a._parse_weight("1") == 1.0

    def test_string_with_leading_text(self):
        a = _analyzer()
        assert a._parse_weight("Weight is 0.6 approximately") == 0.6

    def test_string_with_multiple_numbers_takes_first(self):
        a = _analyzer()
        assert a._parse_weight("0.3 or maybe 0.9") == 0.3

    def test_bool_true_treated_as_numeric(self):
        """bool is subclass of int, so True -> 1.0."""
        a = _analyzer()
        assert a._parse_weight(True) == 1.0

    def test_bool_false_treated_as_numeric(self):
        a = _analyzer()
        assert a._parse_weight(False) == 0.0

    def test_list_returns_default(self):
        a = _analyzer()
        assert a._parse_weight([0.5]) == 1.0

    def test_dict_returns_default(self):
        a = _analyzer()
        assert a._parse_weight({"w": 0.5}) == 1.0


# ===================================================================
# ConstraintAnalyzer.extract_constraints — parsing edge cases
# ===================================================================


class TestExtractConstraintsEdgeCases:
    def test_value_containing_colon(self):
        """Values with colons should be preserved after the first split."""
        a = _analyzer()
        _mock_response(
            a,
            (
                "CONSTRAINT_1:\n"
                "Type: temporal\n"
                "Description: Time range\n"
                "Value: between 10:00 and 14:00\n"
                "Weight: 0.9\n"
            ),
        )
        cs = a.extract_constraints("query")
        assert len(cs) == 1
        assert cs[0].value == "between 10:00 and 14:00"

    def test_noise_lines_are_ignored(self):
        """Lines that don't match key: value or CONSTRAINT_ should be skipped."""
        a = _analyzer()
        _mock_response(
            a,
            (
                "Here is my analysis:\n"
                "\n"
                "CONSTRAINT_1:\n"
                "Type: property\n"
                "Description: Must be red\n"
                "Value: red\n"
                "Weight: 0.7\n"
                "\n"
                "Let me also note something extra.\n"
            ),
        )
        cs = a.extract_constraints("query")
        assert len(cs) == 1
        assert cs[0].value == "red"

    def test_think_tags_wrapping_entire_block(self):
        """Think tags around the whole response should be stripped."""
        a = _analyzer()
        _mock_response(
            a,
            (
                "<think>I need to think about this carefully.\n"
                "Let me consider the constraints.</think>\n"
                "CONSTRAINT_1:\n"
                "Type: location\n"
                "Description: Must be in Europe\n"
                "Value: Europe\n"
                "Weight: 0.8\n"
            ),
        )
        cs = a.extract_constraints("query")
        assert len(cs) == 1
        assert cs[0].type == ConstraintType.LOCATION

    def test_orphaned_think_close_tag(self):
        a = _analyzer()
        _mock_response(
            a,
            (
                "</think>\n"
                "CONSTRAINT_1:\n"
                "Type: event\n"
                "Description: Happened recently\n"
                "Value: recent event\n"
            ),
        )
        cs = a.extract_constraints("query")
        assert len(cs) == 1

    def test_only_last_constraint_present(self):
        """A single constraint without a following CONSTRAINT_ marker."""
        a = _analyzer()
        _mock_response(
            a,
            (
                "CONSTRAINT_1:\n"
                "Type: existence\n"
                "Description: Must exist\n"
                "Value: exists\n"
                "Weight: 1.0\n"
            ),
        )
        cs = a.extract_constraints("query")
        assert len(cs) == 1
        assert cs[0].id == "c1"

    def test_missing_type_still_has_required_fields(self):
        """If type is missing, constraint is skipped because 'type' is not in required keys check.
        Wait — the code only checks for type/description/value. Let's verify type IS required."""
        a = _analyzer()
        _mock_response(a, ("CONSTRAINT_1:\nDescription: No type\nValue: val\n"))
        cs = a.extract_constraints("query")
        # type is not in the required-keys check (only type, description, value)
        # Actually the check is: k in ["type", "description", "value"]
        # Without a Type: line, current_constraint won't have "type" key
        # So the constraint should be skipped
        assert len(cs) == 0

    def test_irrelevant_key_value_lines_ignored(self):
        """Lines like 'Notes: something' should not corrupt the constraint."""
        a = _analyzer()
        _mock_response(
            a,
            (
                "CONSTRAINT_1:\n"
                "Type: property\n"
                "Description: Must be blue\n"
                "Value: blue\n"
                "Weight: 0.5\n"
                "Notes: This is just a note\n"
            ),
        )
        cs = a.extract_constraints("query")
        assert len(cs) == 1
        assert cs[0].value == "blue"
        # "Notes" should not appear anywhere in the constraint
        assert cs[0].description == "Must be blue"

    def test_constraint_numbering_is_independent_of_model_numbering(self):
        """Even if model uses CONSTRAINT_5 and CONSTRAINT_10, IDs are c1, c2."""
        a = _analyzer()
        _mock_response(
            a,
            (
                "CONSTRAINT_5:\n"
                "Type: property\n"
                "Description: A\n"
                "Value: a\n"
                "\n"
                "CONSTRAINT_10:\n"
                "Type: event\n"
                "Description: B\n"
                "Value: b\n"
            ),
        )
        cs = a.extract_constraints("query")
        assert len(cs) == 2
        assert cs[0].id == "c1"
        assert cs[1].id == "c2"

    def test_whitespace_only_response(self):
        a = _analyzer()
        _mock_response(a, "   \n  \n   ")
        cs = a.extract_constraints("query")
        assert cs == []

    def test_weight_with_annotation_parsed_in_full_flow(self):
        """End-to-end: model returns weight like '0.7 (moderate)'."""
        a = _analyzer()
        _mock_response(
            a,
            (
                "CONSTRAINT_1:\n"
                "Type: comparison\n"
                "Description: Greater than 100\n"
                "Value: >100\n"
                "Weight: 0.7 (moderate importance)\n"
            ),
        )
        cs = a.extract_constraints("query")
        assert len(cs) == 1
        assert cs[0].weight == 0.7

    def test_three_constraints_with_one_invalid_middle(self):
        """First valid, second missing value, third valid — should yield 2 constraints."""
        a = _analyzer()
        _mock_response(
            a,
            (
                "CONSTRAINT_1:\n"
                "Type: property\n"
                "Description: First\n"
                "Value: v1\n"
                "\n"
                "CONSTRAINT_2:\n"
                "Type: temporal\n"
                "Description: Missing value field\n"
                "\n"
                "CONSTRAINT_3:\n"
                "Type: location\n"
                "Description: Third\n"
                "Value: v3\n"
            ),
        )
        cs = a.extract_constraints("query")
        assert len(cs) == 2
        assert cs[0].type == ConstraintType.PROPERTY
        assert cs[1].type == ConstraintType.LOCATION
        assert cs[0].id == "c1"
        assert cs[1].id == "c2"

    def test_unknown_type_defaults_to_property_in_full_flow(self):
        a = _analyzer()
        _mock_response(
            a,
            (
                "CONSTRAINT_1:\n"
                "Type: magical\n"
                "Description: Unknown type\n"
                "Value: val\n"
            ),
        )
        cs = a.extract_constraints("query")
        assert len(cs) == 1
        assert cs[0].type == ConstraintType.PROPERTY

    def test_model_invoke_receives_query_in_prompt(self):
        """Verify the query text is passed to the model."""
        a = _analyzer()
        _mock_response(a, "")
        a.extract_constraints("What is the capital of France?")
        call_args = a.model.invoke.call_args[0][0]
        assert "What is the capital of France?" in call_args


# ===================================================================
# Constraint list operations (filtering, sorting by criticality)
# ===================================================================


class TestConstraintListOperations:
    def test_filter_critical_constraints(self):
        constraints = [
            _make(id="c1", type_=ConstraintType.PROPERTY, weight=0.5),
            _make(id="c2", type_=ConstraintType.NAME_PATTERN, weight=0.3),
            _make(id="c3", type_=ConstraintType.TEMPORAL, weight=0.9),
            _make(id="c4", type_=ConstraintType.LOCATION, weight=0.7),
        ]
        critical = [c for c in constraints if c.is_critical()]
        assert len(critical) == 2
        assert {c.id for c in critical} == {"c2", "c3"}

    def test_sort_constraints_by_weight(self):
        constraints = [
            _make(id="c1", weight=0.3),
            _make(id="c2", weight=0.9),
            _make(id="c3", weight=0.6),
        ]
        sorted_cs = sorted(constraints, key=lambda c: c.weight, reverse=True)
        assert [c.id for c in sorted_cs] == ["c2", "c3", "c1"]

    def test_to_search_terms_for_mixed_types(self):
        constraints = [
            _make(type_=ConstraintType.PROPERTY, value="glacier"),
            _make(type_=ConstraintType.EVENT, value="landslide"),
            _make(type_=ConstraintType.TEMPORAL, value="2023"),
        ]
        terms = [c.to_search_terms() for c in constraints]
        assert terms[0] == "glacier"
        assert "accident incident" in terms[1]
        assert terms[2] == "2023"
