"""
Coverage tests for CrossConstraintManager.

Tests cover missing branches in:
- _analyze_pair: parsing Type/Strength/Evidence lines, invalid strength
- analyze_constraint_relationships: strength threshold filtering, graph update
- _calculate_cluster_coherence: no pairs, single constraint, size factor
- _deduplicate_clusters: exact duplicates removed, unique kept
- _format_constraints_for_clustering / _format_constraints_for_query
- optimize_search_order: sorted by coherence * size descending
- generate_cross_constraint_queries: calls sub-generators, stores in cluster
- _generate_intersection_query: NONE response returns None
- _generate_validation_queries: limited to 2 queries
- validate_candidate_across_constraints: complementary relationship boost
- _validate_with_cluster: no queries triggers generation
- create_constraint_clusters: pre-existing relationships skipped
"""

from unittest.mock import MagicMock, patch

from local_deep_research.advanced_search_system.constraints.base_constraint import (
    Constraint,
    ConstraintType,
)
from local_deep_research.advanced_search_system.candidates.base_candidate import (
    Candidate,
)
from local_deep_research.advanced_search_system.search_optimization.cross_constraint_manager import (
    ConstraintCluster,
    ConstraintRelationship,
    CrossConstraintManager,
)

MODULE = "local_deep_research.advanced_search_system.search_optimization.cross_constraint_manager"


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


def _make_manager():
    """Create a CrossConstraintManager with a mocked model."""
    model = MagicMock()
    return CrossConstraintManager(model=model), model


def _constraint(
    cid, description="desc", ctype=ConstraintType.PROPERTY, weight=1.0
):
    return Constraint(
        id=cid,
        type=ctype,
        description=description,
        value=description,
        weight=weight,
    )


def _cluster(
    constraints, cluster_type="type_based", coherence=0.5, queries=None
):
    c = ConstraintCluster(
        constraints=constraints,
        cluster_type=cluster_type,
        coherence_score=coherence,
    )
    if queries is not None:
        c.search_queries = queries
    return c


# ---------------------------------------------------------------------------
# _analyze_pair tests
# ---------------------------------------------------------------------------


class TestAnalyzePair:
    """Tests for _analyze_pair LLM response parsing."""

    def test_parses_type_strength_evidence(self):
        """Valid response lines are parsed into relationship fields."""
        manager, model = _make_manager()
        model.invoke.return_value = MagicMock(
            content="Type: complementary\nStrength: 0.8\nEvidence: they overlap"
        )
        c1 = _constraint("c1")
        c2 = _constraint("c2")

        rel = manager._analyze_pair(c1, c2)

        assert rel.relationship_type == "complementary"
        assert rel.strength == 0.8
        assert rel.evidence == ["they overlap"]
        assert rel.constraint1_id == "c1"
        assert rel.constraint2_id == "c2"

    def test_invalid_strength_defaults_to_zero(self):
        """Non-numeric strength value is caught and defaults to 0.0."""
        manager, model = _make_manager()
        model.invoke.return_value = MagicMock(
            content="Type: dependent\nStrength: high\nEvidence: some reason"
        )
        c1 = _constraint("c1")
        c2 = _constraint("c2")

        rel = manager._analyze_pair(c1, c2)

        assert rel.strength == 0.0

    def test_no_matching_lines_gives_defaults(self):
        """Response with no matching prefixes yields none/0.0 defaults."""
        manager, model = _make_manager()
        model.invoke.return_value = MagicMock(content="Nothing useful here")
        c1 = _constraint("c1")
        c2 = _constraint("c2")

        rel = manager._analyze_pair(c1, c2)

        assert rel.relationship_type == "none"
        assert rel.strength == 0.0
        assert rel.evidence == []

    def test_remove_think_tags_called(self):
        """remove_think_tags is applied to the model response content."""
        manager, model = _make_manager()
        model.invoke.return_value = MagicMock(
            content="<think>hidden</think>Type: exclusive\nStrength: 0.5"
        )
        with patch(
            f"{MODULE}.remove_think_tags",
            return_value="Type: exclusive\nStrength: 0.5",
        ) as mock_rtt:
            c1 = _constraint("c1")
            c2 = _constraint("c2")
            manager._analyze_pair(c1, c2)
            mock_rtt.assert_called_once()


# ---------------------------------------------------------------------------
# analyze_constraint_relationships tests
# ---------------------------------------------------------------------------


class TestAnalyzeConstraintRelationships:
    """Tests for analyze_constraint_relationships filtering and graph update."""

    def test_low_strength_relationship_excluded(self):
        """Relationships with strength <= 0.3 are not stored."""
        manager, model = _make_manager()
        model.invoke.return_value = MagicMock(
            content="Type: none\nStrength: 0.2\nEvidence: weak"
        )
        constraints = [_constraint("c1"), _constraint("c2")]

        result = manager.analyze_constraint_relationships(constraints)

        assert len(result) == 0
        assert not manager.constraint_graph

    def test_high_strength_relationship_stored(self):
        """Relationships with strength > 0.3 are stored and graph updated."""
        manager, model = _make_manager()
        model.invoke.return_value = MagicMock(
            content="Type: complementary\nStrength: 0.9\nEvidence: strong"
        )
        constraints = [_constraint("c1"), _constraint("c2")]

        result = manager.analyze_constraint_relationships(constraints)

        assert ("c1", "c2") in result
        assert "c2" in manager.constraint_graph["c1"]
        assert "c1" in manager.constraint_graph["c2"]

    def test_three_constraints_all_pairs_analyzed(self):
        """For 3 constraints, itertools.combinations produces 3 pairs."""
        manager, model = _make_manager()
        model.invoke.return_value = MagicMock(
            content="Type: complementary\nStrength: 0.8"
        )
        constraints = [_constraint(f"c{i}") for i in range(3)]

        manager.analyze_constraint_relationships(constraints)

        assert model.invoke.call_count == 3


# ---------------------------------------------------------------------------
# _calculate_cluster_coherence tests
# ---------------------------------------------------------------------------


class TestCalculateClusterCoherence:
    """Tests for _calculate_cluster_coherence."""

    def test_single_constraint_returns_zero(self):
        """Fewer than 2 constraints returns 0.0."""
        manager, _ = _make_manager()
        result = manager._calculate_cluster_coherence([_constraint("c1")])
        assert result == 0.0

    def test_no_relationships_returns_default(self):
        """No relationships in self.relationships returns 0.5 default."""
        manager, _ = _make_manager()
        # Empty relationships dict - no entries for any pair
        result = manager._calculate_cluster_coherence(
            [_constraint("c1"), _constraint("c2")]
        )
        assert result == 0.5

    def test_with_relationships_uses_average_strength(self):
        """When relationships exist, coherence is based on average strength."""
        manager, _ = _make_manager()
        manager.relationships[("c1", "c2")] = ConstraintRelationship(
            constraint1_id="c1",
            constraint2_id="c2",
            relationship_type="complementary",
            strength=0.8,
        )
        c1 = _constraint("c1")
        c2 = _constraint("c2")

        result = manager._calculate_cluster_coherence([c1, c2])

        # avg_strength=0.8, size_factor = min(2/5.0, 1.0)=0.4 -> 0.8*(0.7+0.3*0.4)=0.8*0.82=0.656
        assert 0.5 < result < 1.0

    def test_five_constraints_size_factor_capped(self):
        """size_factor is capped at 1.0 for 5 or more constraints."""
        manager, _ = _make_manager()
        constraints = [_constraint(f"c{i}") for i in range(5)]
        # Add relationship for first pair
        manager.relationships[("c0", "c1")] = ConstraintRelationship(
            constraint1_id="c0",
            constraint2_id="c1",
            relationship_type="complementary",
            strength=1.0,
        )

        result = manager._calculate_cluster_coherence(constraints)

        # avg_strength over 10 pairs: only c0-c1 exists so 1.0/1 = 1.0
        # size_factor = min(5/5.0, 1.0) = 1.0 -> 1.0 * (0.7 + 0.3*1.0) = 1.0
        assert result == 1.0


# ---------------------------------------------------------------------------
# _deduplicate_clusters tests
# ---------------------------------------------------------------------------


class TestDeduplicateClusters:
    """Tests for _deduplicate_clusters."""

    def test_exact_duplicate_removed(self):
        """Two clusters with the same constraint IDs: only first kept."""
        manager, _ = _make_manager()
        c1, c2 = _constraint("c1"), _constraint("c2")
        clusters = [
            _cluster([c1, c2], cluster_type="type_based"),
            _cluster([c2, c1], cluster_type="semantic"),  # same set
        ]

        result = manager._deduplicate_clusters(clusters)

        assert len(result) == 1
        assert result[0].cluster_type == "type_based"

    def test_unique_clusters_both_kept(self):
        """Two clusters with different sets: both kept."""
        manager, _ = _make_manager()
        c1, c2, c3 = _constraint("c1"), _constraint("c2"), _constraint("c3")
        clusters = [
            _cluster([c1, c2]),
            _cluster([c2, c3]),
        ]

        result = manager._deduplicate_clusters(clusters)

        assert len(result) == 2

    def test_empty_list_returns_empty(self):
        """Empty input returns empty output."""
        manager, _ = _make_manager()
        assert manager._deduplicate_clusters([]) == []


# ---------------------------------------------------------------------------
# optimize_search_order tests
# ---------------------------------------------------------------------------


class TestOptimizeSearchOrder:
    """Tests for optimize_search_order."""

    def test_sorted_by_coherence_times_size_descending(self):
        """Clusters with highest coherence * num_constraints come first."""
        manager, _ = _make_manager()
        c1, c2, c3 = _constraint("c1"), _constraint("c2"), _constraint("c3")
        clusters = [
            _cluster([c1], coherence=0.9),  # score = 0.9 * 1 = 0.9
            _cluster([c2, c3], coherence=0.6),  # score = 0.6 * 2 = 1.2
        ]

        result = manager.optimize_search_order(clusters)

        assert result[0].coherence_score == 0.6  # higher product comes first

    def test_empty_list_returns_empty(self):
        """Empty input produces empty output."""
        manager, _ = _make_manager()
        assert manager.optimize_search_order([]) == []


# ---------------------------------------------------------------------------
# _generate_intersection_query tests
# ---------------------------------------------------------------------------


class TestGenerateIntersectionQuery:
    """Tests for _generate_intersection_query."""

    def test_single_constraint_returns_none(self):
        """Less than 2 constraints short-circuits to None without LLM call."""
        manager, model = _make_manager()
        result = manager._generate_intersection_query([_constraint("c1")])
        assert result is None
        model.invoke.assert_not_called()

    def test_none_response_returns_none(self):
        """When LLM returns 'NONE', method returns None."""
        manager, model = _make_manager()
        model.invoke.return_value = MagicMock(content="NONE")
        with patch(f"{MODULE}.remove_think_tags", return_value="NONE"):
            result = manager._generate_intersection_query(
                [_constraint("c1"), _constraint("c2")]
            )
        assert result is None

    def test_valid_response_returned(self):
        """Non-NONE LLM response is returned as query string."""
        manager, model = _make_manager()
        model.invoke.return_value = MagicMock(content="climate AND temperature")
        with patch(
            f"{MODULE}.remove_think_tags",
            return_value="climate AND temperature",
        ):
            result = manager._generate_intersection_query(
                [_constraint("c1"), _constraint("c2")]
            )
        assert result == "climate AND temperature"


# ---------------------------------------------------------------------------
# _generate_validation_queries tests
# ---------------------------------------------------------------------------


class TestGenerateValidationQueries:
    """Tests for _generate_validation_queries."""

    def test_limited_to_two_queries(self):
        """At most 2 queries returned even for 3 constraints (3 pairs)."""
        manager, model = _make_manager()
        model.invoke.return_value = MagicMock(content="search query")
        with patch(f"{MODULE}.remove_think_tags", return_value="search query"):
            constraints = [_constraint(f"c{i}") for i in range(3)]
            result = manager._generate_validation_queries(constraints)
        assert len(result) == 2

    def test_one_or_two_constraints_yields_one_query(self):
        """With 2 constraints there is exactly 1 pair -> 1 query (capped at 2)."""
        manager, model = _make_manager()
        model.invoke.return_value = MagicMock(content="pair query")
        with patch(f"{MODULE}.remove_think_tags", return_value="pair query"):
            result = manager._generate_validation_queries(
                [_constraint("c1"), _constraint("c2")]
            )
        assert len(result) == 1


# ---------------------------------------------------------------------------
# validate_candidate_across_constraints tests
# ---------------------------------------------------------------------------


class TestValidateCandidateAcrossConstraints:
    """Tests for validate_candidate_across_constraints."""

    def test_no_clusters_returns_empty(self):
        """When no clusters exist scores dict is empty."""
        manager, _ = _make_manager()
        candidate = Candidate(name="Test")
        result = manager.validate_candidate_across_constraints(
            candidate, [_constraint("c1")]
        )
        assert result == {}

    def test_complementary_relationship_boosts_pair_scores(self):
        """When c1-c2 is 'complementary', _validate_pair is called to boost."""
        manager, model = _make_manager()
        c1 = _constraint("c1")
        c2 = _constraint("c2")

        # Register as complementary relationship
        manager.relationships[("c1", "c2")] = ConstraintRelationship(
            constraint1_id="c1",
            constraint2_id="c2",
            relationship_type="complementary",
            strength=0.9,
        )

        candidate = Candidate(name="MyCandidate")

        with patch.object(
            manager, "_validate_pair", return_value=0.75
        ) as mock_vp:
            result = manager.validate_candidate_across_constraints(
                candidate, [c1, c2]
            )

        mock_vp.assert_called_once_with(candidate, c1, c2)
        assert result.get("c1") == 0.75
        assert result.get("c2") == 0.75


# ---------------------------------------------------------------------------
# _validate_with_cluster tests
# ---------------------------------------------------------------------------


class TestValidateWithCluster:
    """Tests for _validate_with_cluster."""

    def test_no_queries_triggers_generation(self):
        """If cluster has no search_queries, generate_cross_constraint_queries is called."""
        manager, model = _make_manager()
        c1 = _constraint("c1")
        cluster = _cluster([c1], queries=[])

        with patch.object(
            manager,
            "generate_cross_constraint_queries",
            return_value=["generated query"],
        ) as mock_gen:
            model.invoke.return_value = MagicMock(content="Score: 0.7")
            with patch(
                f"{MODULE}.remove_think_tags", return_value="Score: 0.7"
            ):
                manager._validate_with_cluster(Candidate(name="X"), cluster)
            mock_gen.assert_called_once_with(cluster)

    def test_parses_score_from_response(self):
        """Score line is correctly parsed from model response."""
        manager, model = _make_manager()
        c1 = _constraint("c1")
        cluster = _cluster([c1], queries=["existing query"])
        candidate = Candidate(name="MyEntity")

        model.invoke.return_value = MagicMock(
            content="Score: 0.85\nExplanation: strong match"
        )
        with patch(
            f"{MODULE}.remove_think_tags",
            return_value="Score: 0.85\nExplanation: strong match",
        ):
            score = manager._validate_with_cluster(candidate, cluster)

        assert score == 0.85

    def test_invalid_score_defaults_to_zero(self):
        """Non-numeric score value defaults to 0.0."""
        manager, model = _make_manager()
        c1 = _constraint("c1")
        cluster = _cluster([c1], queries=["q"])

        model.invoke.return_value = MagicMock(content="Score: high")
        with patch(f"{MODULE}.remove_think_tags", return_value="Score: high"):
            score = manager._validate_with_cluster(Candidate(name="X"), cluster)

        assert score == 0.0


# ---------------------------------------------------------------------------
# _format helpers tests
# ---------------------------------------------------------------------------


class TestFormatHelpers:
    """Tests for _format_constraints_for_clustering and _format_constraints_for_query."""

    def test_format_clustering_includes_id_and_type(self):
        """Clustering format includes constraint id, description, type and weight."""
        manager, _ = _make_manager()
        c = _constraint(
            "c1", "some description", ConstraintType.TEMPORAL, weight=0.5
        )

        result = manager._format_constraints_for_clustering([c])

        assert "c1" in result
        assert "some description" in result
        assert "temporal" in result
        assert "0.5" in result

    def test_format_query_includes_description_and_type(self):
        """Query format includes constraint description and type."""
        manager, _ = _make_manager()
        c = _constraint("c1", "spatial filter", ConstraintType.LOCATION)

        result = manager._format_constraints_for_query([c])

        assert "spatial filter" in result
        assert "location" in result
