"""Tests for AdaptiveExplorer class."""

import pytest
from unittest.mock import Mock, patch
from collections import defaultdict

from local_deep_research.advanced_search_system.candidate_exploration.adaptive_explorer import (
    AdaptiveExplorer,
)
from local_deep_research.advanced_search_system.candidates.base_candidate import (
    Candidate,
)
from local_deep_research.advanced_search_system.constraints.base_constraint import (
    Constraint,
)
from local_deep_research.advanced_search_system.candidate_exploration.base_explorer import (
    ExplorationStrategy,
)


# ============================================================================
# Fixtures
# ============================================================================


@pytest.fixture
def mock_model():
    """Create a mock LLM model."""
    model = Mock()
    model.invoke.return_value = Mock(content="Generated query response")
    return model


@pytest.fixture
def mock_search_engine():
    """Create a mock search engine."""
    engine = Mock()
    engine.run.return_value = {
        "results": [
            {"title": "Result 1", "snippet": "Snippet 1"},
            {"title": "Result 2", "snippet": "Snippet 2"},
        ],
        "query": "test query",
    }
    return engine


@pytest.fixture
def explorer(mock_model, mock_search_engine):
    """Create an AdaptiveExplorer instance."""
    return AdaptiveExplorer(
        model=mock_model,
        search_engine=mock_search_engine,
        max_candidates=10,
        max_search_time=1.0,
    )


@pytest.fixture
def mock_constraint():
    """Create a mock constraint."""
    constraint = Mock(spec=Constraint)
    constraint.value = "test_value"
    constraint.name = "test_constraint"
    return constraint


@pytest.fixture
def mock_candidates():
    """Create a list of mock candidates."""
    return [
        Mock(spec=Candidate, name="Candidate A", confidence=0.9),
        Mock(spec=Candidate, name="Candidate B", confidence=0.8),
        Mock(spec=Candidate, name="Candidate C", confidence=0.7),
    ]


# ============================================================================
# Test Initialization
# ============================================================================


class TestAdaptiveExplorerInit:
    """Tests for AdaptiveExplorer initialization."""

    def test_default_strategies(self, mock_model, mock_search_engine):
        """Test default strategies are set."""
        explorer = AdaptiveExplorer(
            model=mock_model, search_engine=mock_search_engine
        )
        assert explorer.initial_strategies == [
            "direct_search",
            "synonym_expansion",
            "category_exploration",
            "related_terms",
        ]

    def test_custom_strategies(self, mock_model, mock_search_engine):
        """Test custom strategies are used."""
        custom_strategies = ["strategy_a", "strategy_b"]
        explorer = AdaptiveExplorer(
            model=mock_model,
            search_engine=mock_search_engine,
            initial_strategies=custom_strategies,
        )
        assert explorer.initial_strategies == custom_strategies

    def test_default_adaptation_threshold(self, mock_model, mock_search_engine):
        """Test default adaptation threshold."""
        explorer = AdaptiveExplorer(
            model=mock_model, search_engine=mock_search_engine
        )
        assert explorer.adaptation_threshold == 5

    def test_custom_adaptation_threshold(self, mock_model, mock_search_engine):
        """Test custom adaptation threshold."""
        explorer = AdaptiveExplorer(
            model=mock_model,
            search_engine=mock_search_engine,
            adaptation_threshold=10,
        )
        assert explorer.adaptation_threshold == 10

    def test_strategy_stats_initialized(self, explorer):
        """Test strategy stats are initialized as defaultdict."""
        assert isinstance(explorer.strategy_stats, defaultdict)
        # Access non-existent key should create default entry
        stats = explorer.strategy_stats["new_strategy"]
        assert stats == {
            "attempts": 0,
            "candidates_found": 0,
            "quality_sum": 0.0,
        }

    def test_current_strategy_is_first(self, explorer):
        """Test current strategy is first in list."""
        assert explorer.current_strategy == explorer.initial_strategies[0]


# ============================================================================
# Test explore() Method
# ============================================================================


class TestExplore:
    """Tests for the explore() method."""

    def test_returns_exploration_result(self, explorer):
        """Test explore returns ExplorationResult."""
        with patch.object(
            explorer, "_should_continue_exploration", side_effect=[True, False]
        ):
            result = explorer.explore("test query")
            assert hasattr(result, "candidates")
            assert hasattr(result, "total_searched")
            assert hasattr(result, "exploration_paths")

    def test_result_metadata_contains_strategy_info(self, explorer):
        """Test result metadata includes strategy information."""
        with patch.object(
            explorer, "_should_continue_exploration", side_effect=[True, False]
        ):
            result = explorer.explore("test query")
            assert result.metadata["strategy"] == "adaptive"
            assert "strategy_stats" in result.metadata
            assert "final_strategy" in result.metadata

    def test_strategy_used_is_adaptive(self, explorer):
        """Test strategy_used is ADAPTIVE."""
        with patch.object(
            explorer, "_should_continue_exploration", side_effect=[True, False]
        ):
            result = explorer.explore("test query")
            assert result.strategy_used == ExplorationStrategy.ADAPTIVE

    def test_tracks_exploration_paths(self, explorer):
        """Test exploration paths are tracked."""
        with patch.object(
            explorer, "_should_continue_exploration", side_effect=[True, False]
        ):
            result = explorer.explore("test query")
            assert isinstance(result.exploration_paths, list)

    def test_limits_to_max_candidates(self, explorer, mock_model):
        """Test results are limited to max_candidates."""
        explorer.max_candidates = 2
        mock_model.invoke.return_value = Mock(content="C1\nC2\nC3\nC4\nC5")
        with patch.object(
            explorer, "_should_continue_exploration", side_effect=[True, False]
        ):
            result = explorer.explore("test query")
            assert len(result.candidates) <= 2

    def test_includes_elapsed_time(self, explorer):
        """Test result includes elapsed time."""
        with patch.object(
            explorer, "_should_continue_exploration", side_effect=[True, False]
        ):
            result = explorer.explore("test query")
            assert result.elapsed_time >= 0

    def test_accepts_entity_type(self, explorer):
        """Test entity_type is passed through."""
        with patch.object(
            explorer, "_should_continue_exploration", side_effect=[True, False]
        ):
            result = explorer.explore("test query", entity_type="person")
            assert result.metadata["entity_type"] == "person"

    def test_accepts_constraints(self, explorer, mock_constraint):
        """Test constraints are accepted."""
        with patch.object(
            explorer, "_should_continue_exploration", side_effect=[True, False]
        ):
            result = explorer.explore(
                "test query", constraints=[mock_constraint]
            )
            assert result is not None


# ============================================================================
# Test generate_exploration_queries() Method
# ============================================================================


class TestGenerateExplorationQueries:
    """Tests for generate_exploration_queries() method."""

    def test_returns_list_of_queries(self, explorer):
        """Test returns a list of queries."""
        result = explorer.generate_exploration_queries("base query", [])
        assert isinstance(result, list)

    def test_skips_none_queries(self, explorer):
        """Test None queries are not included."""
        with patch.object(
            explorer, "_generate_query_with_strategy"
        ) as mock_gen:
            mock_gen.return_value = None
            result = explorer.generate_exploration_queries("base", [])
            assert len(result) == 0


# ============================================================================
# Test _choose_strategy() Method
# ============================================================================


class TestChooseStrategy:
    """Tests for _choose_strategy() method."""

    def test_returns_current_strategy_before_threshold(self, explorer):
        """Test returns current strategy before adaptation threshold."""
        explorer.current_strategy = "direct_search"
        explorer.adaptation_threshold = 5
        result = explorer._choose_strategy(3)
        assert result == "direct_search"

    def test_returns_best_strategy_after_threshold(self, explorer):
        """Test returns best strategy after threshold."""
        explorer.adaptation_threshold = 5
        explorer.strategy_stats["synonym_expansion"]["attempts"] = 10
        explorer.strategy_stats["synonym_expansion"]["candidates_found"] = 100
        explorer.strategy_stats["direct_search"]["attempts"] = 10
        explorer.strategy_stats["direct_search"]["candidates_found"] = 10

        result = explorer._choose_strategy(5)
        assert result == "synonym_expansion"

    def test_returns_current_if_no_best(self, explorer):
        """Test returns current strategy if no best found."""
        explorer.current_strategy = "direct_search"
        with patch.object(explorer, "_get_top_strategies", return_value=[]):
            result = explorer._choose_strategy(10)
            assert result == "direct_search"


# ============================================================================
# Test _get_top_strategies() Method
# ============================================================================


class TestGetTopStrategies:
    """Tests for _get_top_strategies() method."""

    def test_returns_initial_when_no_stats(self, explorer):
        """Test returns initial strategies when no stats."""
        result = explorer._get_top_strategies(2)
        assert result == explorer.initial_strategies[:2]

    def test_sorts_by_candidates_per_attempt(self, explorer):
        """Test sorts strategies by candidates found per attempt."""
        explorer.strategy_stats["strategy_a"]["attempts"] = 10
        explorer.strategy_stats["strategy_a"]["candidates_found"] = 100
        explorer.strategy_stats["strategy_b"]["attempts"] = 10
        explorer.strategy_stats["strategy_b"]["candidates_found"] = 50

        result = explorer._get_top_strategies(2)
        assert result[0] == "strategy_a"
        assert result[1] == "strategy_b"

    def test_handles_zero_attempts(self, explorer):
        """Test handles zero attempts gracefully."""
        explorer.strategy_stats["strategy_a"]["attempts"] = 0
        explorer.strategy_stats["strategy_a"]["candidates_found"] = 0

        result = explorer._get_top_strategies(1)
        # Should not raise division by zero
        assert isinstance(result, list)

    def test_limits_to_n_strategies(self, explorer):
        """Test limits results to n strategies."""
        explorer.strategy_stats["a"]["attempts"] = 1
        explorer.strategy_stats["b"]["attempts"] = 1
        explorer.strategy_stats["c"]["attempts"] = 1
        explorer.strategy_stats["d"]["attempts"] = 1

        result = explorer._get_top_strategies(2)
        assert len(result) == 2


# ============================================================================
# Test _generate_query_with_strategy() Method
# ============================================================================


class TestGenerateQueryWithStrategy:
    """Tests for _generate_query_with_strategy() method."""

    def test_direct_search_strategy(self, explorer):
        """Test direct_search strategy."""
        with patch.object(
            explorer, "_direct_search_query", return_value="direct query"
        ) as mock:
            result = explorer._generate_query_with_strategy(
                "base", "direct_search", [], None
            )
            mock.assert_called_with("base")
            assert result == "direct query"

    def test_synonym_expansion_strategy(self, explorer):
        """Test synonym_expansion strategy."""
        with patch.object(
            explorer, "_synonym_expansion_query", return_value="synonym query"
        ) as mock:
            result = explorer._generate_query_with_strategy(
                "base", "synonym_expansion", [], None
            )
            mock.assert_called_with("base")
            assert result == "synonym query"

    def test_category_exploration_strategy(self, explorer, mock_candidates):
        """Test category_exploration strategy."""
        with patch.object(
            explorer,
            "_category_exploration_query",
            return_value="category query",
        ) as mock:
            result = explorer._generate_query_with_strategy(
                "base", "category_exploration", mock_candidates, None
            )
            mock.assert_called_with("base", mock_candidates)
            assert result == "category query"

    def test_related_terms_strategy(self, explorer, mock_candidates):
        """Test related_terms strategy."""
        with patch.object(
            explorer, "_related_terms_query", return_value="related query"
        ) as mock:
            result = explorer._generate_query_with_strategy(
                "base", "related_terms", mock_candidates, None
            )
            mock.assert_called_with("base", mock_candidates)
            assert result == "related query"

    def test_constraint_focused_strategy(self, explorer, mock_constraint):
        """Test constraint_focused strategy."""
        with patch.object(
            explorer,
            "_constraint_focused_query",
            return_value="constraint query",
        ) as mock:
            result = explorer._generate_query_with_strategy(
                "base", "constraint_focused", [], [mock_constraint]
            )
            mock.assert_called_with("base", [mock_constraint])
            assert result == "constraint query"

    def test_constraint_focused_without_constraints_returns_direct(
        self, explorer
    ):
        """Test constraint_focused without constraints falls back to direct."""
        with patch.object(
            explorer, "_direct_search_query", return_value="direct query"
        ) as mock:
            result = explorer._generate_query_with_strategy(
                "base", "constraint_focused", [], None
            )
            mock.assert_called()
            assert result == "direct query"

    def test_unknown_strategy_falls_back_to_direct(self, explorer):
        """Test unknown strategy falls back to direct search."""
        with patch.object(
            explorer, "_direct_search_query", return_value="direct query"
        ) as mock:
            result = explorer._generate_query_with_strategy(
                "base", "unknown_strategy", [], None
            )
            mock.assert_called()
            assert result == "direct query"

    def test_handles_exception_returns_none(self, explorer):
        """Test exceptions return None."""
        with patch.object(
            explorer, "_direct_search_query", side_effect=Exception("Error")
        ):
            result = explorer._generate_query_with_strategy(
                "base", "direct_search", [], None
            )
            assert result is None


# ============================================================================
# Test _direct_search_query() Method
# ============================================================================


class TestDirectSearchQuery:
    """Tests for _direct_search_query() method."""

    def test_returns_unexplored_variation(self, explorer):
        """Test returns first unexplored variation."""
        result = explorer._direct_search_query("test")
        assert result in [
            '"test" examples',
            "test list",
            "test instances",
            "types of test",
        ]

    def test_skips_explored_variations(self, explorer):
        """Test skips already explored variations."""
        explorer.explored_queries.add('"test" examples')
        result = explorer._direct_search_query("test")
        assert result != '"test" examples'

    def test_returns_base_when_all_explored(self, explorer):
        """Test returns base query when all variations explored."""
        explorer.explored_queries.add('"test" examples')
        explorer.explored_queries.add("test list")
        explorer.explored_queries.add("test instances")
        explorer.explored_queries.add("types of test")
        result = explorer._direct_search_query("test")
        assert result == "test"


# ============================================================================
# Test _synonym_expansion_query() Method
# ============================================================================


class TestSynonymExpansionQuery:
    """Tests for _synonym_expansion_query() method."""

    def test_uses_model_to_generate(self, explorer, mock_model):
        """Test uses model to generate synonyms."""
        mock_model.invoke.return_value = Mock(content="alternative query")
        result = explorer._synonym_expansion_query("test query")
        assert result == "alternative query"

    def test_returns_none_when_same_as_base(self, explorer, mock_model):
        """Test returns None when result is same as base."""
        mock_model.invoke.return_value = Mock(content="test query")
        result = explorer._synonym_expansion_query("test query")
        assert result is None

    def test_handles_model_exception(self, explorer, mock_model):
        """Test handles model exceptions."""
        mock_model.invoke.side_effect = Exception("Model error")
        result = explorer._synonym_expansion_query("test query")
        assert result is None


# ============================================================================
# Test _category_exploration_query() Method
# ============================================================================


class TestCategoryExplorationQuery:
    """Tests for _category_exploration_query() method."""

    def test_returns_categories_when_no_candidates(self, explorer):
        """Test returns categories query when no candidates."""
        result = explorer._category_exploration_query("test", [])
        assert result == "categories of test"

    def test_uses_candidate_names_when_available(self, explorer):
        """Test uses candidate names when available."""
        # Create mocks with name as an attribute (not Mock's name parameter)
        candidates = []
        for name in ["Candidate A", "Candidate B", "Candidate C"]:
            c = Mock(spec=Candidate)
            c.name = name
            candidates.append(c)

        result = explorer._category_exploration_query("test", candidates)
        assert "similar to" in result
        assert "Candidate A" in result

    def test_limits_to_first_three_candidates(self, explorer):
        """Test only uses first 3 candidates."""
        candidates = []
        for i in range(10):
            c = Mock(spec=Candidate)
            c.name = f"Candidate {i}"
            candidates.append(c)

        result = explorer._category_exploration_query("test", candidates)
        # Should only include first 3
        assert "Candidate 0" in result
        assert "Candidate 9" not in result


# ============================================================================
# Test _related_terms_query() Method
# ============================================================================


class TestRelatedTermsQuery:
    """Tests for _related_terms_query() method."""

    def test_uses_model_to_generate(self, explorer, mock_model):
        """Test uses model to generate related terms."""
        mock_model.invoke.return_value = Mock(content="related term query")
        result = explorer._related_terms_query("test query", [])
        assert result == "related term query"

    def test_returns_none_when_same_as_base(self, explorer, mock_model):
        """Test returns None when result is same as base."""
        mock_model.invoke.return_value = Mock(content="test query")
        result = explorer._related_terms_query("test query", [])
        assert result is None

    def test_handles_model_exception(self, explorer, mock_model):
        """Test handles model exceptions."""
        mock_model.invoke.side_effect = Exception("Model error")
        result = explorer._related_terms_query("test query", [])
        assert result is None


# ============================================================================
# Test _constraint_focused_query() Method
# ============================================================================


class TestConstraintFocusedQuery:
    """Tests for _constraint_focused_query() method."""

    def test_returns_none_when_no_constraints(self, explorer):
        """Test returns None when no constraints."""
        result = explorer._constraint_focused_query("test", [])
        assert result is None

    def test_uses_first_constraint_value(self, explorer, mock_constraint):
        """Test uses first constraint's value."""
        mock_constraint.value = "constraint_value"
        result = explorer._constraint_focused_query("test", [mock_constraint])
        assert result == "test constraint_value"

    def test_combines_base_with_constraint(self, explorer, mock_constraint):
        """Test combines base query with constraint."""
        mock_constraint.value = "founded 2020"
        result = explorer._constraint_focused_query(
            "companies", [mock_constraint]
        )
        assert result == "companies founded 2020"


# ============================================================================
# Test _update_strategy_stats() Method
# ============================================================================


class TestUpdateStrategyStats:
    """Tests for _update_strategy_stats() method."""

    def test_increments_attempts(self, explorer):
        """Test increments attempts counter."""
        explorer._update_strategy_stats("test_strategy", [])
        assert explorer.strategy_stats["test_strategy"]["attempts"] == 1
        explorer._update_strategy_stats("test_strategy", [])
        assert explorer.strategy_stats["test_strategy"]["attempts"] == 2

    def test_adds_candidates_found(self, explorer, mock_candidates):
        """Test adds to candidates found counter."""
        explorer._update_strategy_stats("test_strategy", mock_candidates)
        assert explorer.strategy_stats["test_strategy"][
            "candidates_found"
        ] == len(mock_candidates)

    def test_adds_quality_sum(self, explorer, mock_candidates):
        """Test adds to quality sum."""
        explorer._update_strategy_stats("test_strategy", mock_candidates)
        expected_quality = len(mock_candidates) * 0.1
        assert (
            explorer.strategy_stats["test_strategy"]["quality_sum"]
            == expected_quality
        )

    def test_handles_empty_candidates(self, explorer):
        """Test handles empty candidates list."""
        explorer._update_strategy_stats("test_strategy", [])
        assert explorer.strategy_stats["test_strategy"]["candidates_found"] == 0
        assert explorer.strategy_stats["test_strategy"]["quality_sum"] == 0.0


# ============================================================================
# Test _adapt_strategy() Method
# ============================================================================


class TestAdaptStrategy:
    """Tests for _adapt_strategy() method."""

    def test_changes_to_best_strategy(self, explorer):
        """Test changes to best performing strategy."""
        explorer.current_strategy = "direct_search"
        explorer.strategy_stats["synonym_expansion"]["attempts"] = 10
        explorer.strategy_stats["synonym_expansion"]["candidates_found"] = 100

        explorer._adapt_strategy()
        assert explorer.current_strategy == "synonym_expansion"

    def test_keeps_current_if_already_best(self, explorer):
        """Test keeps current strategy if already best."""
        explorer.current_strategy = "direct_search"
        explorer.strategy_stats["direct_search"]["attempts"] = 10
        explorer.strategy_stats["direct_search"]["candidates_found"] = 100

        explorer._adapt_strategy()
        assert explorer.current_strategy == "direct_search"

    def test_does_not_change_when_no_stats(self, explorer):
        """Test does not change when no stats available."""
        original = explorer.current_strategy
        explorer._adapt_strategy()
        assert explorer.current_strategy == original


# ============================================================================
# Test _try_next_strategy() Method
# ============================================================================


class TestTryNextStrategy:
    """Tests for _try_next_strategy() method."""

    def test_advances_to_next_strategy(self, explorer):
        """Test advances to next strategy in list."""
        explorer.current_strategy = "direct_search"
        result = explorer._try_next_strategy()
        assert result is True
        assert explorer.current_strategy == "synonym_expansion"

    def test_returns_false_when_all_tried(self, explorer):
        """Test returns False when all strategies tried."""
        explorer.current_strategy = "related_terms"  # Last in default list
        result = explorer._try_next_strategy()
        assert result is False

    def test_wraps_around_index(self, explorer):
        """Test handles wrap-around correctly."""
        # Set to last strategy
        explorer.current_strategy = explorer.initial_strategies[-1]
        result = explorer._try_next_strategy()
        # Should return False (we've tried all)
        assert result is False

    def test_handles_unknown_current_strategy(self, explorer):
        """Test handles unknown current strategy."""
        explorer.current_strategy = "unknown_strategy"
        result = explorer._try_next_strategy()
        # Should start from beginning
        assert result is True
        assert explorer.current_strategy == "synonym_expansion"


# ============================================================================
# Test Integration Scenarios
# ============================================================================


class TestIntegrationScenarios:
    """Integration tests for AdaptiveExplorer."""

    def test_full_exploration_cycle(self, explorer, mock_model):
        """Test a complete exploration cycle."""
        mock_model.invoke.return_value = Mock(
            content="Candidate 1\nCandidate 2"
        )

        with patch.object(
            explorer,
            "_should_continue_exploration",
            side_effect=[True, True, False],
        ):
            result = explorer.explore("test query")

            assert result is not None
            assert result.total_searched >= 1
            assert result.strategy_used == ExplorationStrategy.ADAPTIVE

    def test_strategy_adaptation_over_time(self, explorer, mock_model):
        """Test strategy adapts based on performance."""
        explorer.adaptation_threshold = 1

        # Mock to make synonym_expansion more successful
        call_count = [0]

        def mock_extract(results, entity_type):
            call_count[0] += 1
            strategy = explorer.current_strategy
            num_candidates = 5 if strategy == "synonym_expansion" else 1
            candidates = []
            for i in range(num_candidates):
                c = Mock(spec=Candidate)
                c.name = f"Candidate_{call_count[0]}_{i}"
                c.confidence = 0.8
                c.metadata = {"query": "test"}
                candidates.append(c)
            return candidates

        with patch.object(
            explorer,
            "_should_continue_exploration",
            side_effect=[True, True, True, False],
        ):
            with patch.object(
                explorer,
                "_extract_candidates_from_results",
                side_effect=mock_extract,
            ):
                explorer.explore("test query")
                # After exploration, strategy stats should have entries
                assert len(explorer.strategy_stats) > 0

    def test_handles_no_results(self, explorer, mock_model, mock_search_engine):
        """Test handles case where search returns no results."""
        mock_search_engine.run.return_value = {"results": [], "query": "test"}
        mock_model.invoke.return_value = Mock(content="")

        with patch.object(
            explorer, "_should_continue_exploration", side_effect=[True, False]
        ):
            result = explorer.explore("test query")
            assert result is not None
            assert isinstance(result.candidates, list)

    def test_respects_max_search_time(self, explorer):
        """Test respects max search time limit."""
        # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: mock_time_or_strengthen).
        explorer.max_search_time = 0.01  # Very short

        # Should stop quickly due to time limit
        result = explorer.explore("test query")
        assert result.elapsed_time >= 0
