"""
High-value pure logic tests for rate_limiting/tracker.py.

Focuses on core algorithm correctness that existing tests do not cover:
- _apply_profile: exact arithmetic on both exploration_rate and learning_rate
- get_wait_time: SearXNG default, programmatic mode without context, bounds clamping
- record_outcome + _update_estimate integration: full end-to-end learning cycles
- _update_estimate: mixed success/failure, settings_context learning rate override
"""

import random
import time
from collections import deque
from unittest.mock import MagicMock, patch

from local_deep_research.config.thread_settings import NoSettingsContextError

MODULE = "local_deep_research.web_search_engines.rate_limiting.tracker"


def _make_tracker(**overrides):
    """Create a tracker in programmatic mode with no DB or settings context."""
    with patch(f"{MODULE}.get_setting_from_snapshot") as mock_gs:
        mock_gs.side_effect = NoSettingsContextError("test")
        with patch(f"{MODULE}.logger"):
            from local_deep_research.web_search_engines.rate_limiting.tracker import (
                AdaptiveRateLimitTracker,
            )

            defaults = {"programmatic_mode": True}
            defaults.update(overrides)
            tracker = AdaptiveRateLimitTracker(**defaults)

    if "enabled" not in overrides:
        tracker.enabled = True

    return tracker


def _attempt(wait_time, success, result_count=None):
    """Build an attempt dict for seeding."""
    return {
        "wait_time": wait_time,
        "success": success,
        "timestamp": time.time(),
        "retry_count": 1,
        "search_result_count": result_count,
    }


# ── TestApplyProfile ─────────────────────────────────────────────────


class TestApplyProfile:
    """Exact arithmetic verification for _apply_profile."""

    def test_conservative_reduces_exploration_rate_exact(self):
        """Conservative: exploration_rate = min(rate * 0.5, 0.05)."""
        tracker = _make_tracker()
        tracker.exploration_rate = 0.1
        tracker._apply_profile("conservative")
        # 0.1 * 0.5 = 0.05, min(0.05, 0.05) = 0.05
        assert tracker.exploration_rate == 0.05

    def test_conservative_reduces_learning_rate_exact(self):
        """Conservative: learning_rate = min(rate * 0.7, 0.2)."""
        tracker = _make_tracker()
        tracker.learning_rate = 0.3
        tracker._apply_profile("conservative")
        # 0.3 * 0.7 = 0.21, min(0.21, 0.2) = 0.2
        assert tracker.learning_rate == 0.2

    def test_aggressive_increases_exploration_rate_exact(self):
        """Aggressive: exploration_rate = min(rate * 1.5, 0.2)."""
        tracker = _make_tracker()
        tracker.exploration_rate = 0.1
        tracker._apply_profile("aggressive")
        # 0.1 * 1.5 = 0.15, min(0.15, 0.2) = 0.15
        assert abs(tracker.exploration_rate - 0.15) < 1e-9

    def test_aggressive_increases_learning_rate_exact(self):
        """Aggressive: learning_rate = min(rate * 1.3, 0.5)."""
        tracker = _make_tracker()
        tracker.learning_rate = 0.3
        tracker._apply_profile("aggressive")
        # 0.3 * 1.3 = 0.39, min(0.39, 0.5) = 0.39
        assert abs(tracker.learning_rate - 0.39) < 1e-9

    def test_aggressive_caps_exploration_at_020(self):
        """Aggressive caps exploration_rate at 0.2 when product exceeds it."""
        tracker = _make_tracker()
        tracker.exploration_rate = 0.18
        tracker._apply_profile("aggressive")
        # 0.18 * 1.5 = 0.27, min(0.27, 0.2) = 0.2
        assert tracker.exploration_rate == 0.2

    def test_conservative_caps_exploration_at_005(self):
        """Conservative caps exploration_rate at 0.05 when product exceeds it."""
        tracker = _make_tracker()
        tracker.exploration_rate = 0.2
        tracker._apply_profile("conservative")
        # 0.2 * 0.5 = 0.1, min(0.1, 0.05) = 0.05
        assert tracker.exploration_rate == 0.05

    def test_balanced_leaves_both_rates_unchanged(self):
        """Balanced profile does not modify exploration_rate or learning_rate."""
        tracker = _make_tracker()
        tracker.exploration_rate = 0.123
        tracker.learning_rate = 0.456
        tracker._apply_profile("balanced")
        assert tracker.exploration_rate == 0.123
        assert tracker.learning_rate == 0.456

    def test_unknown_profile_treated_as_balanced(self):
        """Any unrecognized profile name falls through to balanced (no-op)."""
        tracker = _make_tracker()
        tracker.exploration_rate = 0.1
        tracker.learning_rate = 0.3
        tracker._apply_profile("nonexistent_profile")
        assert tracker.exploration_rate == 0.1
        assert tracker.learning_rate == 0.3


# ── TestGetWaitTime ──────────────────────────────────────────────────


class TestGetWaitTime:
    """Tests for get_wait_time covering engine-specific defaults and edge cases."""

    def test_disabled_tracker_returns_01(self):
        """Disabled tracker returns 0.1 regardless of engine type."""
        tracker = _make_tracker()
        tracker.enabled = False
        assert tracker.get_wait_time("AnyEngine") == 0.1

    def test_searxng_engine_gets_01_default(self):
        """SearXNGSearchEngine (self-hosted) gets 0.1 optimistic default."""
        tracker = _make_tracker()
        with patch(
            f"{MODULE}.get_search_context", return_value={"username": "u"}
        ):
            result = tracker.get_wait_time("SearXNGSearchEngine")
        assert result == 0.1

    def test_unknown_engine_gets_01_default(self):
        """Unknown engine with no estimates gets optimistic 0.1 default."""
        tracker = _make_tracker()
        with patch(
            f"{MODULE}.get_search_context", return_value={"username": "u"}
        ):
            result = tracker.get_wait_time("BrandNewEngine")
        assert result == 0.1

    def test_programmatic_mode_works_without_context(self):
        """In programmatic mode, get_wait_time works even with no search context."""
        tracker = _make_tracker()
        tracker.programmatic_mode = True
        with patch(f"{MODULE}.get_search_context", return_value=None):
            result = tracker.get_wait_time("SomeEngine")
        # Should return optimistic default, not 0.0
        assert result == 0.1

    def test_known_engine_exploitation_within_bounds(self):
        """Exploitation path (no exploration) returns value within [min, max]."""
        tracker = _make_tracker()
        tracker.exploration_rate = 0.0  # never explore
        tracker.current_estimates["Eng"] = {
            "base": 2.0,
            "min": 1.5,
            "max": 3.0,
            "confidence": 0.9,
        }
        with patch(
            f"{MODULE}.get_search_context", return_value={"username": "u"}
        ):
            for _ in range(50):
                wait = tracker.get_wait_time("Eng")
                assert 1.5 <= wait <= 3.0

    def test_known_engine_exploration_within_bounds(self):
        """Exploration path returns value within [min, max]."""
        tracker = _make_tracker()
        tracker.exploration_rate = 1.0  # always explore
        tracker.current_estimates["Eng"] = {
            "base": 2.0,
            "min": 0.5,
            "max": 4.0,
            "confidence": 0.9,
        }
        with patch(
            f"{MODULE}.get_search_context", return_value={"username": "u"}
        ):
            for _ in range(50):
                wait = tracker.get_wait_time("Eng")
                assert 0.5 <= wait <= 4.0

    def test_exploration_tends_lower_than_exploitation(self):
        """Exploration (0.5-0.9x) should produce lower average than exploitation (0.9-1.1x)."""
        random.seed(42)  # Deterministic for CI reproducibility
        tracker = _make_tracker()
        tracker.current_estimates["Eng"] = {
            "base": 2.0,
            "min": 0.1,
            "max": 10.0,
            "confidence": 0.9,
        }

        explore_waits = []
        exploit_waits = []

        with patch(
            f"{MODULE}.get_search_context", return_value={"username": "u"}
        ):
            tracker.exploration_rate = 1.0
            for _ in range(200):
                explore_waits.append(tracker.get_wait_time("Eng"))

            tracker.exploration_rate = 0.0
            for _ in range(200):
                exploit_waits.append(tracker.get_wait_time("Eng"))

        avg_explore = sum(explore_waits) / len(explore_waits)
        avg_exploit = sum(exploit_waits) / len(exploit_waits)
        assert avg_explore < avg_exploit


# ── TestRecordOutcome ────────────────────────────────────────────────


class TestRecordOutcome:
    """Tests for record_outcome focusing on settings_context integration."""

    def test_settings_context_overrides_memory_window(self):
        """When settings_context is available, its memory_window is used for deque."""
        tracker = _make_tracker()
        tracker.memory_window = 100  # default

        mock_ctx = MagicMock()
        mock_ctx.get_setting.return_value = 5  # Override to 5

        with patch(f"{MODULE}.get_settings_context", return_value=mock_ctx):
            for i in range(10):
                tracker.record_outcome("Eng", 0.1, True, 1)

        # Deque maxlen should be 5, so only last 5 remain
        assert len(tracker.recent_attempts["Eng"]) == 5

    def test_no_settings_context_uses_instance_memory_window(self):
        """Without settings_context, falls back to tracker.memory_window."""
        tracker = _make_tracker()
        tracker.memory_window = 3

        with patch(f"{MODULE}.get_settings_context", return_value=None):
            for i in range(10):
                tracker.record_outcome("Eng", 0.1, True, 1)

        assert len(tracker.recent_attempts["Eng"]) == 3

    def test_disabled_tracker_does_not_record(self):
        """Disabled tracker skips recording entirely."""
        tracker = _make_tracker()
        tracker.enabled = False
        tracker.record_outcome("Eng", 1.0, True, 1)
        assert "Eng" not in tracker.recent_attempts

    def test_record_triggers_estimate_update_after_3(self):
        """After 3+ outcomes, _update_estimate creates an estimate."""
        tracker = _make_tracker()

        with patch(f"{MODULE}.get_settings_context", return_value=None):
            for i in range(3):
                tracker.record_outcome("Eng", 0.5, True, 1)

        assert "Eng" in tracker.current_estimates
        assert tracker.current_estimates["Eng"]["base"] > 0


# ── TestUpdateEstimate ───────────────────────────────────────────────


class TestUpdateEstimate:
    """Tests for _update_estimate focusing on mixed scenarios and EMA."""

    def test_mixed_success_failure_uses_successful_median(self):
        """With mixed outcomes, base is derived from successful wait times only."""
        tracker = _make_tracker()
        tracker.recent_attempts["Eng"] = deque(
            [
                _attempt(0.5, True),
                _attempt(1.0, True),
                _attempt(5.0, False),  # failed - ignored for median
                _attempt(1.5, True),
            ],
            maxlen=100,
        )

        tracker._update_estimate("Eng")

        est = tracker.current_estimates["Eng"]
        # Sorted successes: [0.5, 1.0, 1.5], index = max(0, int(3*0.5)-1) = 0 -> 0.5
        assert est["base"] == 0.5

    def test_ema_blends_old_and_new(self):
        """EMA formula: new = (1-lr) * old + lr * computed."""
        tracker = _make_tracker()
        tracker.learning_rate = 0.4
        tracker.current_estimates["Eng"] = {
            "base": 5.0,
            "min": 1.0,
            "max": 10.0,
            "confidence": 0.5,
        }
        # All successes at 1.0 -> median = 1.0
        tracker.recent_attempts["Eng"] = deque(
            [_attempt(1.0, True) for _ in range(5)],
            maxlen=100,
        )

        with patch(f"{MODULE}.get_settings_context", return_value=None):
            tracker._update_estimate("Eng")

        # new_base = (1 - 0.4) * 5.0 + 0.4 * 1.0 = 3.0 + 0.4 = 3.4
        assert abs(tracker.current_estimates["Eng"]["base"] - 3.4) < 0.01

    def test_settings_context_overrides_learning_rate(self):
        """When settings_context provides learning_rate, it overrides instance value."""
        tracker = _make_tracker()
        tracker.learning_rate = 0.3  # instance default
        tracker.current_estimates["Eng"] = {
            "base": 4.0,
            "min": 1.0,
            "max": 10.0,
            "confidence": 0.5,
        }
        tracker.recent_attempts["Eng"] = deque(
            [_attempt(2.0, True) for _ in range(5)],
            maxlen=100,
        )

        mock_ctx = MagicMock()
        mock_ctx.get_setting.return_value = 0.5  # Override LR to 0.5

        with patch(f"{MODULE}.get_settings_context", return_value=mock_ctx):
            tracker._update_estimate("Eng")

        # new_base = (1 - 0.5) * 4.0 + 0.5 * 2.0 = 2.0 + 1.0 = 3.0
        assert abs(tracker.current_estimates["Eng"]["base"] - 3.0) < 0.01

    def test_no_successful_waits_caps_at_10(self):
        """All failures with high wait times cap base at 10 seconds."""
        tracker = _make_tracker()
        tracker.recent_attempts["Eng"] = deque(
            [_attempt(20.0, False) for _ in range(3)],
            maxlen=100,
        )

        tracker._update_estimate("Eng")

        # max(20.0) * 1.5 = 30.0, first cap to 10.0, then absolute cap to 10.0
        assert tracker.current_estimates["Eng"]["base"] == 10.0

    def test_bounds_min_floor_at_001(self):
        """min_wait has absolute floor of 0.01."""
        tracker = _make_tracker()
        tracker.recent_attempts["Eng"] = deque(
            [_attempt(0.005, True) for _ in range(5)],
            maxlen=100,
        )

        tracker._update_estimate("Eng")

        est = tracker.current_estimates["Eng"]
        # base = 0.005, min = max(0.01, 0.005*0.5) = max(0.01, 0.0025) = 0.01
        assert est["min"] == 0.01

    def test_bounds_max_ceiling_at_10(self):
        """max_wait has absolute ceiling of 10 seconds."""
        tracker = _make_tracker()
        tracker.recent_attempts["Eng"] = deque(
            [_attempt(5.0, True) for _ in range(5)],
            maxlen=100,
        )

        tracker._update_estimate("Eng")

        est = tracker.current_estimates["Eng"]
        # base=5.0, max_wait = min(10.0, 5.0*3.0) = min(10.0, 15.0) = 10.0
        assert est["max"] == 10.0

    def test_confidence_capped_at_1(self):
        """Confidence = min(attempts/20, 1.0) never exceeds 1.0."""
        tracker = _make_tracker()
        tracker.recent_attempts["Eng"] = deque(
            [_attempt(0.5, True) for _ in range(50)],
            maxlen=100,
        )

        tracker._update_estimate("Eng")

        assert tracker.current_estimates["Eng"]["confidence"] == 1.0


# ── TestEndToEndLearning ─────────────────────────────────────────────


class TestEndToEndLearning:
    """Integration: record outcomes and verify the tracker learns."""

    def test_repeated_successes_converge_estimate(self):
        """Recording many successes at same wait time converges base toward it."""
        tracker = _make_tracker()

        with patch(f"{MODULE}.get_settings_context", return_value=None):
            for _ in range(20):
                tracker.record_outcome("Eng", 0.5, True, 1)

        est = tracker.current_estimates["Eng"]
        # After many updates, base should converge near 0.5
        assert abs(est["base"] - 0.5) < 0.2

    def test_all_failures_produce_higher_estimate_than_successes(self):
        """All-failure path caps higher than all-success path at same wait time."""
        tracker_fail = _make_tracker()
        tracker_success = _make_tracker()

        with patch(f"{MODULE}.get_settings_context", return_value=None):
            # All failures at 2.0
            for _ in range(5):
                tracker_fail.record_outcome("Eng", 2.0, False, 0)

            # All successes at 2.0
            for _ in range(5):
                tracker_success.record_outcome("Eng", 2.0, True, 1)

        fail_base = tracker_fail.current_estimates["Eng"]["base"]
        success_base = tracker_success.current_estimates["Eng"]["base"]
        # All-failures path uses max(waits)*1.5, capped at 10
        # All-successes path uses median of waits
        assert fail_base > success_base

    def test_reset_then_relearn(self):
        """After reset_engine, tracker starts fresh and can learn again."""
        tracker = _make_tracker()

        with patch(f"{MODULE}.get_settings_context", return_value=None):
            for _ in range(5):
                tracker.record_outcome("Eng", 1.0, True, 1)

        assert "Eng" in tracker.current_estimates

        tracker.reset_engine("Eng")
        assert "Eng" not in tracker.current_estimates
        assert "Eng" not in tracker.recent_attempts

        # Re-learn
        with patch(f"{MODULE}.get_settings_context", return_value=None):
            for _ in range(5):
                tracker.record_outcome("Eng", 2.0, True, 1)

        assert "Eng" in tracker.current_estimates
        # Should converge toward 2.0, not 1.0
        assert tracker.current_estimates["Eng"]["base"] > 1.5
