"""
Tests for ConstraintChecker.check_candidate orchestration logic.

Existing tests only cover a mock ConcreteConstraintChecker, not the real
ConstraintChecker with its no-evidence fallback, rejection override, and
weighted average calculation.

Source: src/local_deep_research/advanced_search_system/constraint_checking/constraint_checker.py
"""

from unittest.mock import Mock, patch

import pytest

from local_deep_research.advanced_search_system.constraint_checking.constraint_checker import (
    ConstraintChecker,
    ConstraintCheckResult,
)


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


def _make_constraint(value="test_constraint", weight=1.0, type_val="hard"):
    """Create a mock Constraint."""
    c = Mock()
    c.value = value
    c.weight = weight
    c.type = Mock()
    c.type.value = type_val
    return c


def _make_candidate(name="test_candidate"):
    """Create a mock Candidate."""
    c = Mock()
    c.name = name
    return c


def _make_dual_evidence(positive=0.8, negative=0.1, uncertainty=0.1):
    """Create a mock DualConfidenceEvidence result."""
    e = Mock()
    e.positive_confidence = positive
    e.negative_confidence = negative
    e.uncertainty = uncertainty
    return e


# ---------------------------------------------------------------------------
# No-evidence fallback
# ---------------------------------------------------------------------------


class TestNoEvidenceFallback:
    """When no evidence is found, score = 0.5 - uncertainty_penalty."""

    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.EvidenceAnalyzer"
    )
    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.RejectionEngine"
    )
    def test_default_uncertainty_penalty(
        self, mock_rejection_cls, mock_analyzer_cls
    ):
        """Default penalty 0.2: score = 0.5 - 0.2 = 0.3."""
        mock_rejection = Mock()
        mock_rejection.check_all_constraints.return_value = Mock(
            should_reject=False
        )
        mock_rejection_cls.return_value = mock_rejection

        model = Mock()
        checker = ConstraintChecker(model, evidence_gatherer=lambda c, con: [])

        candidate = _make_candidate()
        constraint = _make_constraint(weight=1.0)

        result = checker.check_candidate(candidate, [constraint])

        assert result.constraint_scores["test_constraint"][
            "total"
        ] == pytest.approx(0.3)
        assert result.constraint_scores["test_constraint"]["positive"] == 0.0
        assert result.constraint_scores["test_constraint"]["negative"] == 0.0
        assert result.constraint_scores["test_constraint"]["uncertainty"] == 1.0

    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.EvidenceAnalyzer"
    )
    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.RejectionEngine"
    )
    def test_custom_uncertainty_penalty(
        self, mock_rejection_cls, mock_analyzer_cls
    ):
        """Custom penalty 0.4: score = 0.5 - 0.4 = 0.1."""
        mock_rejection = Mock()
        mock_rejection.check_all_constraints.return_value = Mock(
            should_reject=False
        )
        mock_rejection_cls.return_value = mock_rejection

        model = Mock()
        checker = ConstraintChecker(
            model,
            evidence_gatherer=lambda c, con: [],
            uncertainty_penalty=0.4,
        )

        candidate = _make_candidate()
        constraint = _make_constraint(weight=1.0)

        result = checker.check_candidate(candidate, [constraint])

        assert result.constraint_scores["test_constraint"][
            "total"
        ] == pytest.approx(0.1)

    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.EvidenceAnalyzer"
    )
    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.RejectionEngine"
    )
    def test_multiple_no_evidence_weighted_average(
        self, mock_rejection_cls, mock_analyzer_cls
    ):
        """Multiple no-evidence constraints: weighted average of fallback scores."""
        mock_rejection = Mock()
        mock_rejection.check_all_constraints.return_value = Mock(
            should_reject=False
        )
        mock_rejection_cls.return_value = mock_rejection

        model = Mock()
        checker = ConstraintChecker(
            model,
            evidence_gatherer=lambda c, con: [],
            uncertainty_penalty=0.2,
        )

        candidate = _make_candidate()
        c1 = _make_constraint("c1", weight=1.0)
        c2 = _make_constraint("c2", weight=2.0)

        result = checker.check_candidate(candidate, [c1, c2])

        # Both have score 0.3, weights 1.0 and 2.0
        # Weighted avg = (0.3*1 + 0.3*2) / 3 = 0.3
        assert result.total_score == pytest.approx(0.3)


# ---------------------------------------------------------------------------
# Evidence gatherer
# ---------------------------------------------------------------------------


class TestEvidenceGatherer:
    """Evidence gatherer is called correctly."""

    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.EvidenceAnalyzer"
    )
    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.RejectionEngine"
    )
    def test_called_once_per_constraint(
        self, mock_rejection_cls, mock_analyzer_cls
    ):
        """Evidence gatherer is called once per constraint."""
        mock_rejection = Mock()
        mock_rejection.check_all_constraints.return_value = Mock(
            should_reject=False
        )
        mock_rejection_cls.return_value = mock_rejection

        mock_analyzer = Mock()
        mock_analyzer.analyze_evidence_dual_confidence.return_value = (
            _make_dual_evidence()
        )
        mock_analyzer.evaluate_evidence_list.return_value = 0.8
        mock_analyzer_cls.return_value = mock_analyzer

        gatherer = Mock(return_value=[{"text": "evidence"}])
        model = Mock()
        checker = ConstraintChecker(model, evidence_gatherer=gatherer)

        candidate = _make_candidate()
        c1 = _make_constraint("c1")
        c2 = _make_constraint("c2")

        checker.check_candidate(candidate, [c1, c2])

        assert gatherer.call_count == 2
        gatherer.assert_any_call(candidate, c1)
        gatherer.assert_any_call(candidate, c2)

    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.EvidenceAnalyzer"
    )
    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.RejectionEngine"
    )
    def test_no_gatherer_returns_empty(
        self, mock_rejection_cls, mock_analyzer_cls
    ):
        """No evidence gatherer provided returns empty evidence."""
        mock_rejection = Mock()
        mock_rejection.check_all_constraints.return_value = Mock(
            should_reject=False
        )
        mock_rejection_cls.return_value = mock_rejection

        model = Mock()
        checker = ConstraintChecker(model)  # No gatherer

        candidate = _make_candidate()
        constraint = _make_constraint()

        result = checker.check_candidate(candidate, [constraint])

        # No evidence -> fallback score
        assert result.constraint_scores["test_constraint"]["uncertainty"] == 1.0


# ---------------------------------------------------------------------------
# Rejection override
# ---------------------------------------------------------------------------


class TestRejectionOverride:
    """Rejection sets total_score to 0.0."""

    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.EvidenceAnalyzer"
    )
    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.RejectionEngine"
    )
    def test_rejection_sets_score_zero(
        self, mock_rejection_cls, mock_analyzer_cls
    ):
        """When rejection_result.should_reject=True, total_score=0.0."""
        mock_rejection = Mock()
        mock_rejection.check_all_constraints.return_value = Mock(
            should_reject=True
        )
        mock_rejection_cls.return_value = mock_rejection

        mock_analyzer = Mock()
        mock_analyzer.analyze_evidence_dual_confidence.return_value = (
            _make_dual_evidence(0.9, 0.0, 0.1)
        )
        mock_analyzer.evaluate_evidence_list.return_value = 0.9
        mock_analyzer_cls.return_value = mock_analyzer

        model = Mock()
        checker = ConstraintChecker(
            model,
            evidence_gatherer=lambda c, con: [{"text": "strong evidence"}],
        )

        candidate = _make_candidate()
        constraint = _make_constraint(weight=1.0)

        result = checker.check_candidate(candidate, [constraint])

        assert result.total_score == 0.0
        assert result.rejection_result.should_reject is True


# ---------------------------------------------------------------------------
# Weighted average calculation
# ---------------------------------------------------------------------------


class TestWeightedAverage:
    """Non-rejection weighted average score calculation."""

    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.EvidenceAnalyzer"
    )
    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.RejectionEngine"
    )
    def test_weighted_average_calculation(
        self, mock_rejection_cls, mock_analyzer_cls
    ):
        """(0.8*1.0 + 0.4*2.0) / (1.0+2.0) = 0.533."""
        mock_rejection = Mock()
        mock_rejection.check_all_constraints.return_value = Mock(
            should_reject=False
        )
        mock_rejection_cls.return_value = mock_rejection

        mock_analyzer = Mock()
        call_count = [0]

        def side_effect_eval(evidence_list, constraint, penalty, neg_weight):
            call_count[0] += 1
            return 0.8 if call_count[0] == 1 else 0.4

        mock_analyzer.analyze_evidence_dual_confidence.return_value = (
            _make_dual_evidence()
        )
        mock_analyzer.evaluate_evidence_list.side_effect = side_effect_eval
        mock_analyzer_cls.return_value = mock_analyzer

        model = Mock()
        checker = ConstraintChecker(
            model,
            evidence_gatherer=lambda c, con: [{"text": "evidence"}],
        )

        candidate = _make_candidate()
        c1 = _make_constraint("c1", weight=1.0)
        c2 = _make_constraint("c2", weight=2.0)

        result = checker.check_candidate(candidate, [c1, c2])

        expected = (0.8 * 1.0 + 0.4 * 2.0) / (1.0 + 2.0)
        assert result.total_score == pytest.approx(expected, abs=0.01)


# ---------------------------------------------------------------------------
# Empty constraints
# ---------------------------------------------------------------------------


class TestEmptyConstraints:
    """Empty constraints list produces total_score = 0.0."""

    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.EvidenceAnalyzer"
    )
    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.RejectionEngine"
    )
    def test_empty_constraints_score_zero(
        self, mock_rejection_cls, mock_analyzer_cls
    ):
        """No constraints -> total_score = 0.0."""
        mock_rejection = Mock()
        mock_rejection.check_all_constraints.return_value = Mock(
            should_reject=False
        )
        mock_rejection_cls.return_value = mock_rejection

        model = Mock()
        checker = ConstraintChecker(model, evidence_gatherer=lambda c, con: [])

        candidate = _make_candidate()

        result = checker.check_candidate(candidate, [])

        assert result.total_score == 0.0
        assert result.constraint_scores == {}
        assert result.detailed_results == []


# ---------------------------------------------------------------------------
# Result structure
# ---------------------------------------------------------------------------


class TestResultStructure:
    """ConstraintCheckResult has correct fields."""

    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.EvidenceAnalyzer"
    )
    @patch(
        "local_deep_research.advanced_search_system.constraint_checking.constraint_checker.RejectionEngine"
    )
    def test_result_fields(self, mock_rejection_cls, mock_analyzer_cls):
        """Result has candidate, total_score, constraint_scores, detailed_results."""
        mock_rejection = Mock()
        mock_rejection.check_all_constraints.return_value = Mock(
            should_reject=False
        )
        mock_rejection_cls.return_value = mock_rejection

        model = Mock()
        checker = ConstraintChecker(model, evidence_gatherer=lambda c, con: [])

        candidate = _make_candidate("my_candidate")
        constraint = _make_constraint("my_constraint", weight=1.0)

        result = checker.check_candidate(candidate, [constraint])

        assert isinstance(result, ConstraintCheckResult)
        assert result.candidate is candidate
        assert isinstance(result.total_score, float)
        assert "my_constraint" in result.constraint_scores
        assert len(result.detailed_results) == 1
        assert result.detailed_results[0]["constraint"] == "my_constraint"
        assert result.detailed_results[0]["weight"] == 1.0
