"""
Coverage tests for diversity_explorer.py

Targets uncovered branches and logic paths in:
- explore() inner loop: diversity threshold met, underrepresented categories,
  new queries generation, empty queries break, skips explored, should_continue check
- _generate_category_queries: generates queries, limits to 3 categories, entity_type as base
- _rank_by_diversity: boosts underrepresented, sorts by score, empty counts
- _calculate_diversity_score: single category->0, uniform->high, skewed->low
"""

from collections import defaultdict
from unittest.mock import patch

import pytest

from local_deep_research.advanced_search_system.candidates.base_candidate import (
    Candidate,
)
from local_deep_research.advanced_search_system.candidate_exploration.base_explorer import (
    ExplorationResult,
    ExplorationStrategy,
)
from local_deep_research.advanced_search_system.candidate_exploration.diversity_explorer import (
    DiversityExplorer,
)


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


def _make_explorer(**overrides):
    """Build a DiversityExplorer with mocked parent __init__."""
    with patch.object(
        DiversityExplorer, "__init__", lambda self, *a, **kw: None
    ):
        e = DiversityExplorer.__new__(DiversityExplorer)
        e.diversity_threshold = overrides.get("diversity_threshold", 0.7)
        e.category_limit = overrides.get("category_limit", 10)
        e.similarity_threshold = overrides.get("similarity_threshold", 0.8)
        e.category_counts = defaultdict(int)
        e.diversity_categories = set()
        e.max_candidates = overrides.get("max_candidates", 50)
        e.max_search_time = overrides.get("max_search_time", 60.0)
        e.explored_queries = set()
        e.found_candidates = {}
        return e


def _candidate(name, category="other", relevance_score=0.5, metadata=None):
    """Create a Candidate with diversity metadata and relevance_score."""
    c = Candidate(
        name=name, metadata=metadata or {"diversity_category": category}
    )
    c.relevance_score = relevance_score
    return c


# ===========================================================================
# explore() inner loop tests
# ===========================================================================


class TestExploreLoop:
    """Tests for the explore() method while-loop logic."""

    def test_diversity_threshold_met_breaks_loop(self):
        """When diversity >= threshold and >= 10 candidates, the loop breaks."""
        # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: keep_with_caveat).
        explorer = _make_explorer(diversity_threshold=0.5)

        initial_candidates = [
            _candidate(f"C{i}", "mountain") for i in range(12)
        ]

        with (
            patch.object(
                explorer, "_execute_search", return_value={"results": []}
            ) as mock_search,
            patch.object(
                explorer,
                "_extract_candidates_from_results",
                return_value=initial_candidates,
            ),
            patch.object(explorer, "_categorize_candidates"),
            patch.object(
                explorer, "_should_continue_exploration", return_value=True
            ),
            patch.object(
                explorer, "_calculate_diversity_score", return_value=0.8
            ),
            patch.object(
                explorer,
                "_final_diversity_selection",
                side_effect=lambda c: c,
            ),
            patch.object(
                explorer, "_rank_by_diversity", side_effect=lambda c, q: c
            ),
        ):
            result = explorer.explore("test query")

            assert isinstance(result, ExplorationResult)
            assert result.strategy_used == ExplorationStrategy.DIVERSITY_FOCUSED
            # Only the initial search was executed
            assert mock_search.call_count == 1

    def test_empty_queries_breaks_loop(self):
        """When generated queries list is empty, the loop breaks."""
        # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: keep_with_caveat).
        explorer = _make_explorer(diversity_threshold=0.99)

        def should_continue(_start, _count):
            return True

        with (
            patch.object(
                explorer, "_execute_search", return_value={"results": []}
            ) as mock_search,
            patch.object(
                explorer,
                "_extract_candidates_from_results",
                return_value=[],
            ),
            patch.object(explorer, "_categorize_candidates"),
            patch.object(
                explorer,
                "_should_continue_exploration",
                side_effect=should_continue,
            ),
            patch.object(
                explorer, "_calculate_diversity_score", return_value=0.1
            ),
            patch.object(
                explorer,
                "_find_underrepresented_categories",
                return_value=[],
            ),
            patch.object(
                explorer, "_generate_diversity_queries", return_value=[]
            ),
            patch.object(
                explorer,
                "_final_diversity_selection",
                side_effect=lambda c: c,
            ),
            patch.object(
                explorer, "_rank_by_diversity", side_effect=lambda c, q: c
            ),
        ):
            explorer.explore("test query")

            assert mock_search.call_count == 1

    def test_skips_already_explored_queries(self):
        """Queries already in explored_queries are skipped."""
        # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: keep_with_caveat).
        explorer = _make_explorer(diversity_threshold=0.99)
        explorer.explored_queries = {"already explored"}

        call_count = {"continue": 0}

        def should_continue(_start, _count):
            call_count["continue"] += 1
            return call_count["continue"] <= 2

        with (
            patch.object(
                explorer, "_execute_search", return_value={"results": []}
            ) as mock_search,
            patch.object(
                explorer,
                "_extract_candidates_from_results",
                return_value=[],
            ),
            patch.object(explorer, "_categorize_candidates"),
            patch.object(
                explorer,
                "_should_continue_exploration",
                side_effect=should_continue,
            ),
            patch.object(
                explorer, "_calculate_diversity_score", return_value=0.1
            ),
            patch.object(
                explorer,
                "_find_underrepresented_categories",
                return_value=["water"],
            ),
            patch.object(
                explorer,
                "_generate_category_queries",
                return_value=["already explored"],
            ),
            patch.object(explorer, "_filter_for_diversity", return_value=[]),
            patch.object(
                explorer,
                "_final_diversity_selection",
                side_effect=lambda c: c,
            ),
            patch.object(
                explorer, "_rank_by_diversity", side_effect=lambda c, q: c
            ),
        ):
            explorer.explore("test query")

            assert mock_search.call_count == 1

    def test_explore_metadata_contains_expected_keys(self):
        """Explore result metadata has strategy, diversity score, categories, entity_type."""
        explorer = _make_explorer()
        explorer.category_counts["mountain"] = 3
        explorer.diversity_categories = {"mountain"}

        with (
            patch.object(
                explorer, "_execute_search", return_value={"results": []}
            ),
            patch.object(
                explorer,
                "_extract_candidates_from_results",
                return_value=[],
            ),
            patch.object(explorer, "_categorize_candidates"),
            patch.object(
                explorer, "_should_continue_exploration", return_value=False
            ),
            patch.object(
                explorer, "_calculate_diversity_score", return_value=0.0
            ),
            patch.object(
                explorer,
                "_final_diversity_selection",
                side_effect=lambda c: c,
            ),
            patch.object(
                explorer, "_rank_by_diversity", side_effect=lambda c, q: c
            ),
        ):
            result = explorer.explore("test", entity_type="landmark")

            assert result.metadata["strategy"] == "diversity_focused"
            assert "final_diversity_score" in result.metadata
            assert result.metadata["categories_found"] == 1
            assert result.metadata["entity_type"] == "landmark"
            assert "category_distribution" in result.metadata


# ===========================================================================
# _generate_category_queries tests
# ===========================================================================


class TestGenerateCategoryQueries:
    """Tests for _generate_category_queries method."""

    def test_generates_two_queries_per_category(self):
        """Each category produces two queries."""
        explorer = _make_explorer()
        queries = explorer._generate_category_queries(
            ["park"], "hiking spots", None
        )

        assert len(queries) == 2
        assert "hiking spots park" in queries
        assert "park examples hiking spots" in queries

    def test_limits_to_three_categories(self):
        """Only the first 3 categories are used even if more are passed."""
        explorer = _make_explorer()
        categories = ["mountain", "water", "park", "trail", "canyon"]
        queries = explorer._generate_category_queries(categories, "query", None)

        assert len(queries) == 6

    def test_entity_type_used_as_base_when_provided(self):
        """When entity_type is given it replaces base_query as the base."""
        explorer = _make_explorer()
        queries = explorer._generate_category_queries(
            ["canyon"], "ignored query", "scenic spots"
        )

        for q in queries:
            assert "scenic spots" in q
        for q in queries:
            assert "ignored query" not in q

    def test_falls_back_to_base_query_when_no_entity_type(self):
        """When entity_type is None, base_query is used."""
        explorer = _make_explorer()
        queries = explorer._generate_category_queries(
            ["trail"], "nature walks", None
        )

        assert any("nature walks" in q for q in queries)


# ===========================================================================
# _rank_by_diversity tests
# ===========================================================================


class TestRankByDiversity:
    """Tests for _rank_by_diversity method."""

    def test_boosts_underrepresented_categories(self):
        """Candidates from underrepresented categories get a positive boost."""
        explorer = _make_explorer()
        explorer.category_counts = defaultdict(int, {"popular": 10, "rare": 1})

        c_pop = _candidate("Popular", "popular", 0.5)
        c_rare = _candidate("Rare", "rare", 0.5)

        with patch.object(
            explorer,
            "_rank_candidates_by_relevance",
            side_effect=lambda c, q: c,
        ):
            result = explorer._rank_by_diversity([c_pop, c_rare], "query")

        rare_c = next(c for c in result if c.name == "Rare")
        pop_c = next(c for c in result if c.name == "Popular")
        assert rare_c.final_score > pop_c.final_score

    def test_sorts_by_final_score_descending(self):
        """Result list is sorted by final_score in descending order."""
        explorer = _make_explorer()
        explorer.category_counts = defaultdict(int, {"a": 1, "b": 5})

        c_low = _candidate("Low", "b", 0.1)
        c_high = _candidate("High", "a", 0.9)

        with patch.object(
            explorer,
            "_rank_candidates_by_relevance",
            side_effect=lambda c, q: c,
        ):
            result = explorer._rank_by_diversity([c_low, c_high], "query")

        assert result[0].final_score >= result[1].final_score

    def test_empty_candidates_returns_empty(self):
        """Empty input produces empty output."""
        explorer = _make_explorer()
        explorer.category_counts = defaultdict(int, {"x": 1})

        with patch.object(
            explorer,
            "_rank_candidates_by_relevance",
            side_effect=lambda c, q: c,
        ):
            result = explorer._rank_by_diversity([], "query")

        assert result == []

    def test_empty_category_counts_causes_division_by_zero(self):
        """When category_counts is empty defaultdict, accessing an unknown
        category auto-creates a 0 entry causing ZeroDivisionError."""
        explorer = _make_explorer()
        explorer.category_counts = defaultdict(int)

        c = _candidate("Solo", "unknown", 0.5)

        with patch.object(
            explorer,
            "_rank_candidates_by_relevance",
            side_effect=lambda c, q: c,
        ):
            with pytest.raises(ZeroDivisionError):
                explorer._rank_by_diversity([c], "query")


# ===========================================================================
# _calculate_diversity_score tests
# ===========================================================================


class TestCalculateDiversityScore:
    """Tests for _calculate_diversity_score method."""

    def test_empty_candidates_returns_zero(self):
        """No candidates -> score 0."""
        explorer = _make_explorer()
        assert explorer._calculate_diversity_score([]) == 0.0

    def test_single_category_raises_attribute_error(self):
        """bit_length() on float raises AttributeError."""
        explorer = _make_explorer()
        candidates = [_candidate(f"C{i}", "mountain") for i in range(5)]
        with pytest.raises(AttributeError):
            explorer._calculate_diversity_score(candidates)

    def test_multiple_categories_raises_attribute_error(self):
        """Due to bit_length() on float, non-empty input raises AttributeError."""
        explorer = _make_explorer()
        candidates = [
            _candidate("A", "mountain"),
            _candidate("B", "water"),
            _candidate("C", "park"),
        ]
        with pytest.raises(AttributeError):
            explorer._calculate_diversity_score(candidates)

    def test_candidates_without_metadata_use_other_category(self):
        """Candidates without diversity_category metadata default to other."""
        explorer = _make_explorer()
        c = Candidate(name="NoMeta")
        c.metadata = {}
        with pytest.raises(AttributeError):
            explorer._calculate_diversity_score([c])


# ===========================================================================
# explore() integration-level edge cases
# ===========================================================================


class TestExploreEdgeCases:
    """Additional edge cases for explore()."""

    def test_explore_slices_to_max_candidates(self):
        """Final result is sliced to max_candidates."""
        # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: keep_with_caveat).
        explorer = _make_explorer(max_candidates=2)

        all_cands = [_candidate(f"C{i}", "other") for i in range(5)]

        with (
            patch.object(
                explorer, "_execute_search", return_value={"results": []}
            ),
            patch.object(
                explorer,
                "_extract_candidates_from_results",
                return_value=all_cands,
            ),
            patch.object(explorer, "_categorize_candidates"),
            patch.object(
                explorer, "_should_continue_exploration", return_value=False
            ),
            patch.object(
                explorer, "_calculate_diversity_score", return_value=0.0
            ),
            patch.object(
                explorer,
                "_final_diversity_selection",
                side_effect=lambda c: c,
            ),
            patch.object(
                explorer, "_rank_by_diversity", side_effect=lambda c, q: c
            ),
        ):
            result = explorer.explore("test")

            assert len(result.candidates) <= 2

    def test_explore_total_searched_increments(self):
        """total_searched increments for each search executed."""
        # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: keep_with_caveat).
        explorer = _make_explorer(diversity_threshold=0.99)

        call_count = {"continue": 0}

        def should_continue(_start, _count):
            call_count["continue"] += 1
            return call_count["continue"] <= 2

        with (
            patch.object(
                explorer, "_execute_search", return_value={"results": []}
            ),
            patch.object(
                explorer,
                "_extract_candidates_from_results",
                return_value=[],
            ),
            patch.object(explorer, "_categorize_candidates"),
            patch.object(
                explorer,
                "_should_continue_exploration",
                side_effect=should_continue,
            ),
            patch.object(
                explorer, "_calculate_diversity_score", return_value=0.1
            ),
            patch.object(
                explorer,
                "_find_underrepresented_categories",
                return_value=["water"],
            ),
            patch.object(
                explorer,
                "_generate_category_queries",
                return_value=["q1"],
            ),
            patch.object(explorer, "_filter_for_diversity", return_value=[]),
            patch.object(
                explorer,
                "_final_diversity_selection",
                side_effect=lambda c: c,
            ),
            patch.object(
                explorer, "_rank_by_diversity", side_effect=lambda c, q: c
            ),
        ):
            result = explorer.explore("test")

            assert result.total_searched >= 2

    def test_explore_exploration_paths_record_diversity_searches(self):
        """Exploration paths include entries for diversity searches."""
        # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: keep_with_caveat).
        explorer = _make_explorer(diversity_threshold=0.99)

        call_count = {"continue": 0}

        def should_continue(_start, _count):
            call_count["continue"] += 1
            return call_count["continue"] <= 2

        with (
            patch.object(
                explorer, "_execute_search", return_value={"results": []}
            ),
            patch.object(
                explorer,
                "_extract_candidates_from_results",
                return_value=[],
            ),
            patch.object(explorer, "_categorize_candidates"),
            patch.object(
                explorer,
                "_should_continue_exploration",
                side_effect=should_continue,
            ),
            patch.object(
                explorer, "_calculate_diversity_score", return_value=0.1
            ),
            patch.object(
                explorer,
                "_find_underrepresented_categories",
                return_value=["water"],
            ),
            patch.object(
                explorer,
                "_generate_category_queries",
                return_value=["water stuff"],
            ),
            patch.object(explorer, "_filter_for_diversity", return_value=[]),
            patch.object(
                explorer,
                "_final_diversity_selection",
                side_effect=lambda c: c,
            ),
            patch.object(
                explorer, "_rank_by_diversity", side_effect=lambda c, q: c
            ),
        ):
            result = explorer.explore("test")

            assert any(
                "Diversity search" in p for p in result.exploration_paths
            )
