"""
Tests for Cross Constraint Manager

Covers the CrossConstraintManager class and related dataclasses including
constraint relationship analysis, clustering, query generation, and validation.
"""

from collections import defaultdict
from dataclasses import fields
from unittest.mock import MagicMock

import pytest

from tests.test_utils import add_src_to_path

add_src_to_path()

from local_deep_research.advanced_search_system.constraints.base_constraint import (  # noqa: E402
    Constraint,
    ConstraintType,
)
from local_deep_research.advanced_search_system.search_optimization.cross_constraint_manager import (  # noqa: E402
    ConstraintCluster,
    ConstraintRelationship,
    CrossConstraintManager,
)


# ============================================================================
# Test Fixtures
# ============================================================================


@pytest.fixture
def mock_model():
    """Create a mock LLM model."""
    model = MagicMock()
    return model


@pytest.fixture
def sample_constraints():
    """Create sample constraints for testing."""
    return [
        Constraint(
            id="c1",
            type=ConstraintType.TEMPORAL,
            description="Happened in 2020",
            value="2020",
            weight=0.9,
        ),
        Constraint(
            id="c2",
            type=ConstraintType.LOCATION,
            description="Located in Colorado",
            value="Colorado",
            weight=0.8,
        ),
        Constraint(
            id="c3",
            type=ConstraintType.PROPERTY,
            description="Has mountain peak",
            value="mountain peak",
            weight=0.7,
        ),
        Constraint(
            id="c4",
            type=ConstraintType.TEMPORAL,
            description="After 2015",
            value="after 2015",
            weight=0.6,
        ),
    ]


@pytest.fixture
def manager(mock_model):
    """Create a CrossConstraintManager instance."""
    return CrossConstraintManager(mock_model)


# ============================================================================
# Tests for ConstraintRelationship Dataclass
# ============================================================================


class TestConstraintRelationship:
    """Tests for ConstraintRelationship dataclass."""

    def test_creation_with_required_fields(self):
        """Should create with required fields."""
        rel = ConstraintRelationship(
            constraint1_id="c1",
            constraint2_id="c2",
            relationship_type="complementary",
            strength=0.8,
        )

        assert rel.constraint1_id == "c1"
        assert rel.constraint2_id == "c2"
        assert rel.relationship_type == "complementary"
        assert rel.strength == 0.8

    def test_default_evidence_list(self):
        """Should have empty list as default evidence."""
        rel = ConstraintRelationship(
            constraint1_id="c1",
            constraint2_id="c2",
            relationship_type="dependent",
            strength=0.5,
        )

        assert rel.evidence == []
        assert isinstance(rel.evidence, list)

    def test_creation_with_evidence(self):
        """Should create with provided evidence."""
        evidence = ["They share temporal context", "Both relate to events"]
        rel = ConstraintRelationship(
            constraint1_id="c1",
            constraint2_id="c2",
            relationship_type="complementary",
            strength=0.9,
            evidence=evidence,
        )

        assert rel.evidence == evidence
        assert len(rel.evidence) == 2

    def test_field_types(self):
        """Should have correct field types."""
        rel_fields = {f.name: f.type for f in fields(ConstraintRelationship)}

        assert rel_fields["constraint1_id"] is str
        assert rel_fields["constraint2_id"] is str
        assert rel_fields["relationship_type"] is str
        assert rel_fields["strength"] is float


# ============================================================================
# Tests for ConstraintCluster Dataclass
# ============================================================================


class TestConstraintCluster:
    """Tests for ConstraintCluster dataclass."""

    def test_creation_with_required_fields(self, sample_constraints):
        """Should create with required fields."""
        cluster = ConstraintCluster(
            constraints=sample_constraints[:2],
            cluster_type="temporal",
            coherence_score=0.75,
        )

        assert len(cluster.constraints) == 2
        assert cluster.cluster_type == "temporal"
        assert cluster.coherence_score == 0.75

    def test_default_search_queries(self, sample_constraints):
        """Should have empty list as default search_queries."""
        cluster = ConstraintCluster(
            constraints=sample_constraints,
            cluster_type="spatial",
            coherence_score=0.6,
        )

        assert cluster.search_queries == []
        assert isinstance(cluster.search_queries, list)

    def test_creation_with_queries(self, sample_constraints):
        """Should create with provided search queries."""
        queries = ["query 1", "query 2"]
        cluster = ConstraintCluster(
            constraints=sample_constraints[:2],
            cluster_type="causal",
            coherence_score=0.8,
            search_queries=queries,
        )

        assert cluster.search_queries == queries
        assert len(cluster.search_queries) == 2


# ============================================================================
# Tests for CrossConstraintManager Initialization
# ============================================================================


class TestCrossConstraintManagerInit:
    """Tests for CrossConstraintManager initialization."""

    def test_initialization_with_model(self, mock_model):
        """Should initialize with provided model."""
        manager = CrossConstraintManager(mock_model)

        assert manager.model is mock_model

    def test_initial_relationships_empty(self, mock_model):
        """Should have empty relationships dict on init."""
        manager = CrossConstraintManager(mock_model)

        assert manager.relationships == {}
        assert isinstance(manager.relationships, dict)

    def test_initial_clusters_empty(self, mock_model):
        """Should have empty clusters list on init."""
        manager = CrossConstraintManager(mock_model)

        assert manager.clusters == []
        assert isinstance(manager.clusters, list)

    def test_initial_cross_validation_patterns(self, mock_model):
        """Should have defaultdict for cross_validation_patterns."""
        manager = CrossConstraintManager(mock_model)

        assert isinstance(manager.cross_validation_patterns, defaultdict)
        # Accessing non-existent key should return empty list
        assert manager.cross_validation_patterns["nonexistent"] == []

    def test_initial_constraint_graph(self, mock_model):
        """Should have defaultdict for constraint_graph."""
        manager = CrossConstraintManager(mock_model)

        assert isinstance(manager.constraint_graph, defaultdict)
        # Accessing non-existent key should return empty set
        assert manager.constraint_graph["nonexistent"] == set()


# ============================================================================
# Tests for analyze_constraint_relationships
# ============================================================================


class TestAnalyzeConstraintRelationships:
    """Tests for analyze_constraint_relationships method."""

    def test_empty_list_returns_empty(self, manager):
        """Should return empty dict for empty constraint list."""
        result = manager.analyze_constraint_relationships([])

        assert result == {}

    def test_single_constraint_returns_empty(self, manager, sample_constraints):
        """Should return empty dict for single constraint."""
        result = manager.analyze_constraint_relationships(
            [sample_constraints[0]]
        )

        assert result == {}

    def test_calls_model_for_pairs(
        self, manager, sample_constraints, mock_model
    ):
        """Should call model.invoke for each constraint pair."""
        mock_response = MagicMock()
        mock_response.content = (
            "Type: complementary\nStrength: 0.8\nEvidence: Related"
        )
        mock_model.invoke.return_value = mock_response

        # Two constraints = 1 pair
        manager.analyze_constraint_relationships(sample_constraints[:2])

        assert mock_model.invoke.call_count == 1

    def test_filters_by_strength_threshold(
        self, manager, sample_constraints, mock_model
    ):
        """Should only keep relationships with strength > 0.3."""
        # First pair: weak relationship
        mock_response_weak = MagicMock()
        mock_response_weak.content = (
            "Type: none\nStrength: 0.1\nEvidence: Unrelated"
        )

        # Second pair: strong relationship
        mock_response_strong = MagicMock()
        mock_response_strong.content = (
            "Type: complementary\nStrength: 0.8\nEvidence: Related"
        )

        mock_model.invoke.side_effect = [
            mock_response_weak,
            mock_response_strong,
        ]

        # Three constraints = 3 pairs, but mock only 2 for simplicity
        result = manager.analyze_constraint_relationships(
            sample_constraints[:2]
        )

        # Weak relationship should not be included
        if result:
            for rel in result.values():
                assert rel.strength > 0.3

    def test_updates_constraint_graph(
        self, manager, sample_constraints, mock_model
    ):
        """Should update constraint_graph for strong relationships."""
        mock_response = MagicMock()
        mock_response.content = (
            "Type: complementary\nStrength: 0.8\nEvidence: Related"
        )
        mock_model.invoke.return_value = mock_response

        manager.analyze_constraint_relationships(sample_constraints[:2])

        # Both constraints should have each other in their graph
        c1_neighbors = manager.constraint_graph["c1"]
        c2_neighbors = manager.constraint_graph["c2"]

        assert "c2" in c1_neighbors or len(manager.relationships) == 0
        assert "c1" in c2_neighbors or len(manager.relationships) == 0


# ============================================================================
# Tests for _analyze_pair
# ============================================================================


class TestAnalyzePair:
    """Tests for _analyze_pair method."""

    def test_parses_response_correctly(
        self, manager, sample_constraints, mock_model
    ):
        """Should parse model response correctly."""
        mock_response = MagicMock()
        mock_response.content = """Type: dependent
Strength: 0.75
Evidence: Temporal ordering required"""
        mock_model.invoke.return_value = mock_response

        result = manager._analyze_pair(
            sample_constraints[0], sample_constraints[1]
        )

        assert result.relationship_type == "dependent"
        assert result.strength == 0.75
        assert "Temporal ordering required" in result.evidence

    def test_handles_malformed_response(
        self, manager, sample_constraints, mock_model
    ):
        """Should handle malformed responses gracefully."""
        mock_response = MagicMock()
        mock_response.content = "This is not formatted correctly at all"
        mock_model.invoke.return_value = mock_response

        result = manager._analyze_pair(
            sample_constraints[0], sample_constraints[1]
        )

        # Should return default values
        assert result.relationship_type == "none"
        assert result.strength == 0.0
        assert result.constraint1_id == "c1"
        assert result.constraint2_id == "c2"

    def test_handles_invalid_strength(
        self, manager, sample_constraints, mock_model
    ):
        """Should handle invalid strength values."""
        mock_response = MagicMock()
        mock_response.content = (
            "Type: complementary\nStrength: not_a_number\nEvidence: Test"
        )
        mock_model.invoke.return_value = mock_response

        result = manager._analyze_pair(
            sample_constraints[0], sample_constraints[1]
        )

        assert result.strength == 0.0

    def test_returns_constraint_ids(
        self, manager, sample_constraints, mock_model
    ):
        """Should return correct constraint IDs."""
        mock_response = MagicMock()
        mock_response.content = (
            "Type: none\nStrength: 0.0\nEvidence: No relation"
        )
        mock_model.invoke.return_value = mock_response

        result = manager._analyze_pair(
            sample_constraints[0], sample_constraints[2]
        )

        assert result.constraint1_id == "c1"
        assert result.constraint2_id == "c3"


# ============================================================================
# Tests for create_constraint_clusters
# ============================================================================


class TestCreateConstraintClusters:
    """Tests for create_constraint_clusters method."""

    def test_creates_type_based_clusters(
        self, manager, sample_constraints, mock_model
    ):
        """Should create clusters based on constraint types."""
        # Set up minimal mock responses
        mock_response = MagicMock()
        mock_response.content = "Type: none\nStrength: 0.1\nEvidence: None"
        mock_model.invoke.return_value = mock_response

        result = manager.create_constraint_clusters(sample_constraints)

        # Should have at least type-based clusters for TEMPORAL (c1, c4)
        type_based = [c for c in result if c.cluster_type == "type_based"]
        assert len(type_based) >= 1

    def test_deduplicates_clusters(
        self, manager, sample_constraints, mock_model
    ):
        """Should remove duplicate clusters."""
        mock_response = MagicMock()
        mock_response.content = "Type: none\nStrength: 0.1\nEvidence: None"
        mock_model.invoke.return_value = mock_response

        result = manager.create_constraint_clusters(sample_constraints)

        # Check for uniqueness
        seen_sets = []
        for cluster in result:
            constraint_set = frozenset(c.id for c in cluster.constraints)
            assert constraint_set not in seen_sets
            seen_sets.append(constraint_set)

    def test_stores_clusters_in_manager(
        self, manager, sample_constraints, mock_model
    ):
        """Should store clusters in manager.clusters."""
        mock_response = MagicMock()
        mock_response.content = "Type: none\nStrength: 0.1\nEvidence: None"
        mock_model.invoke.return_value = mock_response

        result = manager.create_constraint_clusters(sample_constraints)

        assert manager.clusters == result


# ============================================================================
# Tests for _create_relationship_clusters
# ============================================================================


class TestCreateRelationshipClusters:
    """Tests for _create_relationship_clusters method."""

    def test_bfs_clustering(self, manager, sample_constraints):
        """Should use BFS to find connected components."""
        # Create relationships that form a connected component
        relationships = [
            ConstraintRelationship(
                constraint1_id="c1",
                constraint2_id="c2",
                relationship_type="complementary",
                strength=0.8,
            ),
            ConstraintRelationship(
                constraint1_id="c2",
                constraint2_id="c3",
                relationship_type="complementary",
                strength=0.7,
            ),
        ]

        result = manager._create_relationship_clusters(
            sample_constraints[:3], relationships
        )

        # c1, c2, c3 should be in same cluster
        if result:
            cluster_ids = {c.id for c in result[0].constraints}
            assert "c1" in cluster_ids
            assert "c2" in cluster_ids
            assert "c3" in cluster_ids

    def test_isolated_constraints_not_clustered(
        self, manager, sample_constraints
    ):
        """Should not create single-constraint clusters."""
        # Only c1 and c2 are related
        relationships = [
            ConstraintRelationship(
                constraint1_id="c1",
                constraint2_id="c2",
                relationship_type="complementary",
                strength=0.8,
            ),
        ]

        result = manager._create_relationship_clusters(
            sample_constraints, relationships
        )

        # All clusters should have more than one constraint
        for cluster in result:
            assert len(cluster.constraints) > 1


# ============================================================================
# Tests for _deduplicate_clusters
# ============================================================================


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

    def test_removes_exact_duplicates(self, manager, sample_constraints):
        """Should remove exact duplicate clusters."""
        cluster1 = ConstraintCluster(
            constraints=sample_constraints[:2],
            cluster_type="type_based",
            coherence_score=0.7,
        )
        cluster2 = ConstraintCluster(
            constraints=sample_constraints[:2],  # Same constraints
            cluster_type="semantic",  # Different type
            coherence_score=0.8,  # Different score
        )

        result = manager._deduplicate_clusters([cluster1, cluster2])

        assert len(result) == 1

    def test_keeps_different_sets(self, manager, sample_constraints):
        """Should keep clusters with different constraint sets."""
        cluster1 = ConstraintCluster(
            constraints=sample_constraints[:2],
            cluster_type="type_based",
            coherence_score=0.7,
        )
        cluster2 = ConstraintCluster(
            constraints=sample_constraints[2:4],  # Different constraints
            cluster_type="type_based",
            coherence_score=0.7,
        )

        result = manager._deduplicate_clusters([cluster1, cluster2])

        assert len(result) == 2


# ============================================================================
# Tests for generate_cross_constraint_queries
# ============================================================================


class TestGenerateCrossConstraintQueries:
    """Tests for generate_cross_constraint_queries method."""

    def test_generates_multiple_query_types(
        self, manager, sample_constraints, mock_model
    ):
        """Should generate multiple types of queries."""
        cluster = ConstraintCluster(
            constraints=sample_constraints[:3],
            cluster_type="temporal",
            coherence_score=0.8,
        )

        mock_response = MagicMock()
        mock_response.content = "test query"
        mock_model.invoke.return_value = mock_response

        result = manager.generate_cross_constraint_queries(cluster)

        # Should have combined, progressive, and validation queries
        assert len(result) >= 3

    def test_stores_queries_in_cluster(
        self, manager, sample_constraints, mock_model
    ):
        """Should store queries in cluster.search_queries."""
        cluster = ConstraintCluster(
            constraints=sample_constraints[:2],
            cluster_type="temporal",
            coherence_score=0.8,
        )

        mock_response = MagicMock()
        mock_response.content = "test query"
        mock_model.invoke.return_value = mock_response

        result = manager.generate_cross_constraint_queries(cluster)

        assert cluster.search_queries == result


# ============================================================================
# Tests for _generate_intersection_query
# ============================================================================


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

    def test_returns_none_for_single_constraint(
        self, manager, sample_constraints
    ):
        """Should return None for single constraint."""
        result = manager._generate_intersection_query([sample_constraints[0]])

        assert result is None

    def test_handles_none_response(
        self, manager, sample_constraints, mock_model
    ):
        """Should handle NONE response from model."""
        mock_response = MagicMock()
        mock_response.content = "NONE"
        mock_model.invoke.return_value = mock_response

        result = manager._generate_intersection_query(sample_constraints[:2])

        assert result is None

    def test_returns_query_on_success(
        self, manager, sample_constraints, mock_model
    ):
        """Should return query when model provides one."""
        mock_response = MagicMock()
        mock_response.content = "Colorado events in 2020"
        mock_model.invoke.return_value = mock_response

        result = manager._generate_intersection_query(sample_constraints[:2])

        assert result == "Colorado events in 2020"


# ============================================================================
# Tests for validate_candidate_across_constraints
# ============================================================================


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

    def test_with_relevant_clusters(
        self, manager, sample_constraints, mock_model
    ):
        """Should validate using relevant clusters."""
        from local_deep_research.advanced_search_system.candidates.base_candidate import (
            Candidate,
        )

        # Set up a cluster
        cluster = ConstraintCluster(
            constraints=sample_constraints[:2],
            cluster_type="temporal",
            coherence_score=0.8,
            search_queries=["test query"],
        )
        manager.clusters = [cluster]

        # Mock validation response
        mock_response = MagicMock()
        mock_response.content = "Score: 0.85\nExplanation: Good match"
        mock_model.invoke.return_value = mock_response

        candidate = Candidate(name="Test Candidate")

        result = manager.validate_candidate_across_constraints(
            candidate, sample_constraints[:2]
        )

        # Should have scores for constraints
        assert isinstance(result, dict)


# ============================================================================
# Tests for _calculate_cluster_coherence
# ============================================================================


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

    def test_single_constraint_returns_zero(self, manager, sample_constraints):
        """Should return 0.0 for single constraint."""
        result = manager._calculate_cluster_coherence([sample_constraints[0]])

        assert result == 0.0

    def test_no_relationships_returns_default(
        self, manager, sample_constraints
    ):
        """Should return default 0.5 when no relationships found."""
        result = manager._calculate_cluster_coherence(sample_constraints[:2])

        assert result == 0.5

    def test_uses_relationship_strengths(self, manager, sample_constraints):
        """Should calculate based on relationship strengths."""
        # Add a relationship
        manager.relationships[("c1", "c2")] = ConstraintRelationship(
            constraint1_id="c1",
            constraint2_id="c2",
            relationship_type="complementary",
            strength=0.9,
        )

        result = manager._calculate_cluster_coherence(sample_constraints[:2])

        # Should be > 0.5 (default) since we have strong relationship
        assert result > 0.5


# ============================================================================
# Tests for optimize_search_order
# ============================================================================


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

    def test_sorts_by_coherence_times_size(self, manager, sample_constraints):
        """Should sort by coherence * size in descending order."""
        cluster1 = ConstraintCluster(
            constraints=sample_constraints[:2],  # size 2
            cluster_type="type_based",
            coherence_score=0.5,  # score = 2 * 0.5 = 1.0
        )
        cluster2 = ConstraintCluster(
            constraints=sample_constraints[:3],  # size 3
            cluster_type="semantic",
            coherence_score=0.8,  # score = 3 * 0.8 = 2.4
        )
        cluster3 = ConstraintCluster(
            constraints=sample_constraints[:1],  # size 1
            cluster_type="relationship_based",
            coherence_score=0.9,  # score = 1 * 0.9 = 0.9
        )

        result = manager.optimize_search_order([cluster1, cluster2, cluster3])

        # cluster2 should be first (highest score), then cluster1, then cluster3
        assert result[0] == cluster2
        assert result[1] == cluster1
        assert result[2] == cluster3


# ============================================================================
# Tests for helper methods
# ============================================================================


class TestFormatConstraintsForClustering:
    """Tests for _format_constraints_for_clustering method."""

    def test_formats_all_constraint_info(self, manager, sample_constraints):
        """Should include id, description, type, and weight."""
        result = manager._format_constraints_for_clustering(
            sample_constraints[:1]
        )

        assert "c1" in result
        assert "Happened in 2020" in result
        assert "temporal" in result.lower()
        assert "0.9" in result


class TestFormatConstraintsForQuery:
    """Tests for _format_constraints_for_query method."""

    def test_formats_description_and_type(self, manager, sample_constraints):
        """Should include description and type."""
        result = manager._format_constraints_for_query(sample_constraints[:1])

        assert "Happened in 2020" in result
        assert "temporal" in result.lower()
        assert "-" in result  # Should have bullet point
