"""
Coverage tests for rate_limiting/tracker.py DB paths, singleton, error handling.
"""

import time
from collections import deque
from contextlib import contextmanager
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(programmatic_mode=False, **overrides):
    """Create a tracker with mocked settings."""
    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,
            )

            tracker = AdaptiveRateLimitTracker(
                programmatic_mode=programmatic_mode, **overrides
            )
    return tracker


def _attempt(wait_time, success):
    return {
        "wait_time": wait_time,
        "success": success,
        "timestamp": time.time(),
        "retry_count": 1,
        "search_result_count": None,
    }


def _fake_ctx_mgr(mock_session):
    @contextmanager
    def _cm(*args, **kwargs):
        yield mock_session

    return _cm


class TestEnsureEstimatesLoadedDB:
    """Tests for _ensure_estimates_loaded DB-loading code paths."""

    def test_no_db_imports_marks_loaded(self):
        """Empty dict from _get_db_imports marks loaded."""
        tracker = _make_tracker()
        tracker._estimates_loaded = False
        with patch(f"{MODULE}._get_db_imports", return_value={}):
            tracker._ensure_estimates_loaded()
        assert tracker._estimates_loaded is True

    def test_no_model_in_imports_marks_loaded(self):
        """Imports without RateLimitEstimate marks loaded."""
        tracker = _make_tracker()
        tracker._estimates_loaded = False
        imports = {
            "get_user_db_session": MagicMock(),
            "RateLimitAttempt": MagicMock(),
        }
        with patch(f"{MODULE}._get_db_imports", return_value=imports):
            with patch(f"{MODULE}.get_search_context", return_value=None):
                tracker._ensure_estimates_loaded()
        assert tracker._estimates_loaded is True

    def test_no_context_marks_loaded(self):
        """No search context marks loaded."""
        tracker = _make_tracker()
        tracker._estimates_loaded = False
        imports = {
            "RateLimitEstimate": MagicMock(),
            "get_user_db_session": MagicMock(),
        }
        with patch(f"{MODULE}._get_db_imports", return_value=imports):
            with patch(f"{MODULE}.get_search_context", return_value=None):
                tracker._ensure_estimates_loaded()
        assert tracker._estimates_loaded is True

    def test_no_username_does_not_mark_loaded(self):
        """Context with username=None skips the if-branch."""
        tracker = _make_tracker()
        tracker._estimates_loaded = False
        imports = {
            "RateLimitEstimate": MagicMock(),
            "get_user_db_session": MagicMock(),
        }
        ctx = {"username": None, "user_password": "pass"}
        with patch(f"{MODULE}._get_db_imports", return_value=imports):
            with patch(f"{MODULE}.get_search_context", return_value=ctx):
                tracker._ensure_estimates_loaded()
        assert tracker._estimates_loaded is False

    def test_successful_load_with_decay(self):
        """Loads estimates from DB and applies time-based decay."""
        tracker = _make_tracker()
        tracker._estimates_loaded = False
        tracker.decay_per_day = 0.95
        est = MagicMock()
        est.engine_type = "TE"
        est.base_wait_seconds = 2.0
        est.min_wait_seconds = 0.5
        est.max_wait_seconds = 5.0
        est.last_updated = time.time() - 86400
        ms = MagicMock()
        ms.query.return_value.all.return_value = [est]
        mw = MagicMock()
        mw.get_session = _fake_ctx_mgr(ms)
        imports = {
            "RateLimitEstimate": MagicMock(),
            "get_user_db_session": MagicMock(),
        }
        ctx = {"username": "alice", "user_password": "secret"}
        with patch(f"{MODULE}._get_db_imports", return_value=imports):
            with patch(f"{MODULE}.get_search_context", return_value=ctx):
                with patch.dict(
                    "sys.modules",
                    {
                        "local_deep_research.database.thread_metrics": MagicMock(
                            metrics_writer=mw
                        )
                    },
                ):
                    tracker._ensure_estimates_loaded()
        assert tracker._estimates_loaded is True
        assert "TE" in tracker.current_estimates
        assert tracker.current_estimates["TE"]["base"] == 2.0
        assert abs(tracker.current_estimates["TE"]["confidence"] - 0.95) < 0.01

    def test_db_exception_marks_loaded(self):
        """Database exception during load still marks loaded."""
        tracker = _make_tracker()
        tracker._estimates_loaded = False
        imports = {
            "RateLimitEstimate": MagicMock(),
            "get_user_db_session": MagicMock(),
        }
        mw = MagicMock()
        mw.get_session.side_effect = RuntimeError("DB down")
        ctx = {"username": "alice", "user_password": "secret"}
        with patch(f"{MODULE}._get_db_imports", return_value=imports):
            with patch(f"{MODULE}.get_search_context", return_value=ctx):
                with patch.dict(
                    "sys.modules",
                    {
                        "local_deep_research.database.thread_metrics": MagicMock(
                            metrics_writer=mw
                        )
                    },
                ):
                    tracker._ensure_estimates_loaded()
        assert tracker._estimates_loaded is True

    def test_already_loaded_returns_early(self):
        """Already loaded skips DB calls."""
        tracker = _make_tracker()
        tracker._estimates_loaded = True
        with patch(f"{MODULE}._get_db_imports") as mi:
            tracker._ensure_estimates_loaded()
            mi.assert_not_called()

    def test_programmatic_mode_sets_loaded_true(self):
        """Programmatic mode sets loaded without DB access."""
        tracker = _make_tracker(programmatic_mode=True)
        tracker._estimates_loaded = False
        tracker.programmatic_mode = True
        with patch(f"{MODULE}._get_db_imports") as mi:
            tracker._ensure_estimates_loaded()
            mi.assert_not_called()
        assert tracker._estimates_loaded is True


class TestUpdateEstimateDB:
    """Tests for _update_estimate DB persistence path."""

    def _tw(self):
        tracker = _make_tracker(programmatic_mode=False)
        tracker.enabled = True
        tracker.recent_attempts["Eng"] = deque(
            [_attempt(1.0, True) for _ in range(5)], maxlen=100
        )
        return tracker

    def test_persists_new_estimate_to_db(self):
        """Creates new estimate when none exists in DB."""
        tracker = self._tw()
        ms = MagicMock()
        ms.query.return_value.filter_by.return_value.first.return_value = None
        mw = MagicMock()
        mw.get_session = _fake_ctx_mgr(ms)
        imports = {
            "RateLimitEstimate": MagicMock(),
            "get_user_db_session": MagicMock(),
        }
        ctx = {"username": "a", "user_password": "p"}
        with patch(f"{MODULE}.get_search_context", return_value=ctx):
            with patch(f"{MODULE}.get_settings_context", return_value=None):
                with patch(f"{MODULE}._get_db_imports", return_value=imports):
                    with patch.dict(
                        "sys.modules",
                        {
                            "local_deep_research.database.thread_metrics": MagicMock(
                                metrics_writer=mw
                            )
                        },
                    ):
                        tracker._update_estimate("Eng")
        ms.add.assert_called_once()

    def test_updates_existing_estimate_in_db(self):
        """Updates existing estimate in place."""
        tracker = self._tw()
        existing = MagicMock()
        ms = MagicMock()
        ms.query.return_value.filter_by.return_value.first.return_value = (
            existing
        )
        mw = MagicMock()
        mw.get_session = _fake_ctx_mgr(ms)
        imports = {
            "RateLimitEstimate": MagicMock(),
            "get_user_db_session": MagicMock(),
        }
        ctx = {"username": "a", "user_password": "p"}
        with patch(f"{MODULE}.get_search_context", return_value=ctx):
            with patch(f"{MODULE}.get_settings_context", return_value=None):
                with patch(f"{MODULE}._get_db_imports", return_value=imports):
                    with patch.dict(
                        "sys.modules",
                        {
                            "local_deep_research.database.thread_metrics": MagicMock(
                                metrics_writer=mw
                            )
                        },
                    ):
                        tracker._update_estimate("Eng")
        assert existing.base_wait_seconds is not None
        ms.add.assert_not_called()

    def test_no_user_context_skips_db(self):
        """No search context skips DB persistence."""
        tracker = self._tw()
        with patch(f"{MODULE}.get_search_context", return_value=None):
            with patch(f"{MODULE}.get_settings_context", return_value=None):
                with patch(f"{MODULE}._get_db_imports"):
                    tracker._update_estimate("Eng")
        assert "Eng" in tracker.current_estimates

    def test_db_exception_logs_but_doesnt_crash(self):
        """DB write exception is caught, not propagated."""
        tracker = self._tw()
        imports = {
            "RateLimitEstimate": MagicMock(),
            "get_user_db_session": MagicMock(),
        }
        mw = MagicMock()
        mw.get_session.side_effect = RuntimeError("DB locked")
        ctx = {"username": "a", "user_password": "p"}
        with patch(f"{MODULE}.get_search_context", return_value=ctx):
            with patch(f"{MODULE}.get_settings_context", return_value=None):
                with patch(f"{MODULE}._get_db_imports", return_value=imports):
                    with patch.dict(
                        "sys.modules",
                        {
                            "local_deep_research.database.thread_metrics": MagicMock(
                                metrics_writer=mw
                            )
                        },
                    ):
                        tracker._update_estimate("Eng")
        assert "Eng" in tracker.current_estimates


class TestGetStatsDB:
    """Tests for get_stats real DB query path."""

    @patch(f"{MODULE}.is_ci_environment", return_value=False)
    @patch(
        f"{MODULE}.get_search_context",
        return_value={"username": "u", "user_password": "p"},
    )
    def test_full_db_query_returns_estimates(self, _ctx, _ci):
        """Full DB path queries all estimates."""
        tracker = _make_tracker(programmatic_mode=False)
        est = MagicMock(
            engine_type="TE",
            base_wait_seconds=1.5,
            min_wait_seconds=0.5,
            max_wait_seconds=3.0,
            last_updated=time.time(),
            total_attempts=10,
            success_rate=0.9,
        )
        ms = MagicMock()
        ms.query.return_value.order_by.return_value.all.return_value = [est]
        mock_writer = MagicMock()
        mock_writer.get_session = _fake_ctx_mgr(ms)
        imports = {"RateLimitEstimate": MagicMock()}
        with patch(f"{MODULE}._get_db_imports", return_value=imports):
            with patch(
                "local_deep_research.database.thread_metrics.metrics_writer",
                mock_writer,
            ):
                result = tracker.get_stats()
        assert len(result) == 1
        assert result[0][0] == "TE"

    @patch(f"{MODULE}.is_ci_environment", return_value=False)
    @patch(
        f"{MODULE}.get_search_context",
        return_value={"username": "u", "user_password": "p"},
    )
    def test_db_query_with_engine_filter(self, _ctx, _ci):
        """DB path uses filter_by when engine_type is specified."""
        tracker = _make_tracker(programmatic_mode=False)
        est = MagicMock(
            engine_type="G",
            base_wait_seconds=2.0,
            min_wait_seconds=1.0,
            max_wait_seconds=4.0,
            last_updated=time.time(),
            total_attempts=5,
            success_rate=0.8,
        )
        ms = MagicMock()
        ms.query.return_value.filter_by.return_value.all.return_value = [est]
        mock_writer = MagicMock()
        mock_writer.get_session = _fake_ctx_mgr(ms)
        imports = {"RateLimitEstimate": MagicMock()}
        with patch(f"{MODULE}._get_db_imports", return_value=imports):
            with patch(
                "local_deep_research.database.thread_metrics.metrics_writer",
                mock_writer,
            ):
                result = tracker.get_stats(engine_type="G")
        assert len(result) == 1
        assert result[0][0] == "G"

    @patch(f"{MODULE}.is_ci_environment", return_value=False)
    @patch(
        f"{MODULE}.get_search_context",
        return_value={"username": "u", "user_password": "p"},
    )
    def test_db_exception_falls_back_to_in_memory(self, _ctx, _ci):
        """DB exception falls back to in-memory stats."""
        tracker = _make_tracker(programmatic_mode=False)
        tracker.current_estimates["Eng"] = {
            "base": 1.0,
            "min": 0.5,
            "max": 2.0,
            "confidence": 0.7,
        }

        mock_writer = MagicMock()
        mock_writer.get_session.side_effect = RuntimeError("DB error")
        imports = {"RateLimitEstimate": MagicMock()}
        with patch(f"{MODULE}._get_db_imports", return_value=imports):
            with patch(
                "local_deep_research.database.thread_metrics.metrics_writer",
                mock_writer,
            ):
                result = tracker.get_stats()
        assert len(result) == 1
        assert result[0][0] == "Eng"

    @patch(f"{MODULE}.is_ci_environment", return_value=False)
    @patch(f"{MODULE}.get_search_context", return_value=None)
    def test_no_context_falls_back_to_in_memory(self, _ctx, _ci):
        """No search context falls back to in-memory stats."""
        tracker = _make_tracker(programmatic_mode=False)
        tracker.current_estimates["Eng"] = {
            "base": 1.0,
            "min": 0.5,
            "max": 2.0,
            "confidence": 0.7,
        }
        result = tracker.get_stats()
        assert len(result) == 1
        assert result[0][0] == "Eng"


class TestResetEngineDB:
    """Tests for reset_engine DB delete path."""

    @patch(f"{MODULE}.is_ci_environment", return_value=False)
    @patch(
        f"{MODULE}.get_search_context",
        return_value={"username": "u", "user_password": "p"},
    )
    def test_deletes_from_db(self, _ctx, _ci):
        """Deletes from DB and commits."""
        tracker = _make_tracker(programmatic_mode=False)
        tracker.recent_attempts["Eng"] = deque(
            [_attempt(1.0, True)], maxlen=100
        )
        tracker.current_estimates["Eng"] = {
            "base": 1.0,
            "min": 0.5,
            "max": 2.0,
            "confidence": 0.5,
        }
        ms = MagicMock()
        mock_writer = MagicMock()
        mock_writer.get_session = _fake_ctx_mgr(ms)
        imports = {
            "RateLimitAttempt": MagicMock(),
            "RateLimitEstimate": MagicMock(),
        }
        with patch(f"{MODULE}._get_db_imports", return_value=imports):
            with patch(
                "local_deep_research.database.thread_metrics.metrics_writer",
                mock_writer,
            ):
                tracker.reset_engine("Eng")
        assert "Eng" not in tracker.recent_attempts
        assert "Eng" not in tracker.current_estimates
        ms.commit.assert_called_once()

    @patch(f"{MODULE}.is_ci_environment", return_value=False)
    @patch(
        f"{MODULE}.get_search_context",
        return_value={"username": "u", "user_password": "p"},
    )
    def test_db_exception_clears_memory_only(self, _ctx, _ci):
        """DB exception during reset still clears memory."""
        tracker = _make_tracker(programmatic_mode=False)
        tracker.recent_attempts["Eng"] = deque(
            [_attempt(1.0, True)], maxlen=100
        )
        tracker.current_estimates["Eng"] = {
            "base": 1.0,
            "min": 0.5,
            "max": 2.0,
            "confidence": 0.5,
        }
        mock_writer = MagicMock()
        mock_writer.get_session.side_effect = RuntimeError("DB locked")
        imports = {
            "RateLimitAttempt": MagicMock(),
            "RateLimitEstimate": MagicMock(),
        }
        with patch(f"{MODULE}._get_db_imports", return_value=imports):
            with patch(
                "local_deep_research.database.thread_metrics.metrics_writer",
                mock_writer,
            ):
                tracker.reset_engine("Eng")
        assert "Eng" not in tracker.recent_attempts
        assert "Eng" not in tracker.current_estimates


class TestCleanupOldDataDB:
    """Tests for cleanup_old_data real DB cleanup path."""

    def _make_timestamp_mock(self):
        mock_ts = MagicMock()
        mock_ts.__lt__ = MagicMock(return_value=MagicMock())
        mock_rla = MagicMock()
        mock_rla.timestamp = mock_ts
        return mock_rla

    @patch(f"{MODULE}.is_ci_environment", return_value=False)
    @patch(
        f"{MODULE}.get_search_context",
        return_value={"username": "u", "user_password": "p"},
    )
    def test_full_cleanup_deletes_old_rows(self, _ctx, _ci):
        """Deletes old attempts from DB and commits."""
        tracker = _make_tracker(programmatic_mode=False)
        mq = MagicMock()
        mq.count.return_value = 42
        ms = MagicMock()
        ms.query.return_value.filter.return_value = mq
        mock_writer = MagicMock()
        mock_writer.get_session = _fake_ctx_mgr(ms)
        imports = {"RateLimitAttempt": self._make_timestamp_mock()}
        with patch(f"{MODULE}._get_db_imports", return_value=imports):
            with patch(
                "local_deep_research.database.thread_metrics.metrics_writer",
                mock_writer,
            ):
                tracker.cleanup_old_data(days=30)
        mq.delete.assert_called_once()
        ms.commit.assert_called_once()

    @patch(f"{MODULE}.is_ci_environment", return_value=False)
    @patch(
        f"{MODULE}.get_search_context",
        return_value={"username": "u", "user_password": "p"},
    )
    def test_no_old_data_still_commits(self, _ctx, _ci):
        """When no old data exists, still commits."""
        tracker = _make_tracker(programmatic_mode=False)
        mq = MagicMock()
        mq.count.return_value = 0
        ms = MagicMock()
        ms.query.return_value.filter.return_value = mq
        mock_writer = MagicMock()
        mock_writer.get_session = _fake_ctx_mgr(ms)
        imports = {"RateLimitAttempt": self._make_timestamp_mock()}
        with patch(f"{MODULE}._get_db_imports", return_value=imports):
            with patch(
                "local_deep_research.database.thread_metrics.metrics_writer",
                mock_writer,
            ):
                tracker.cleanup_old_data(days=7)
        ms.commit.assert_called_once()

    @patch(f"{MODULE}.is_ci_environment", return_value=False)
    @patch(
        f"{MODULE}.get_search_context",
        return_value={"username": "u", "user_password": "p"},
    )
    def test_db_exception_does_not_propagate(self, _ctx, _ci):
        """DB exception during cleanup is caught."""
        tracker = _make_tracker(programmatic_mode=False)
        mock_writer = MagicMock()
        mock_writer.get_session.side_effect = RuntimeError("DB error")
        imports = {"RateLimitAttempt": MagicMock()}
        with patch(f"{MODULE}._get_db_imports", return_value=imports):
            with patch(
                "local_deep_research.database.thread_metrics.metrics_writer",
                mock_writer,
            ):
                tracker.cleanup_old_data(days=30)


class TestGetTrackerFactory:
    """Tests for get_tracker() factory."""

    def test_creates_fresh_instance_each_call(self):
        """Each call returns a new tracker instance."""
        import local_deep_research.web_search_engines.rate_limiting.tracker as mod

        with patch(f"{MODULE}.get_setting_from_snapshot") as gs:
            gs.side_effect = NoSettingsContextError("test")
            with patch(f"{MODULE}.logger"):
                result1 = mod.get_tracker()
                result2 = mod.get_tracker()
        assert isinstance(result1, mod.AdaptiveRateLimitTracker)
        assert isinstance(result2, mod.AdaptiveRateLimitTracker)
        assert result1 is not result2


class TestGetDbImportsError:
    """Tests for _get_db_imports ImportError path."""

    def test_import_error_returns_empty_dict(self):
        """When database imports fail, returns empty dict."""
        import local_deep_research.web_search_engines.rate_limiting.tracker as mod

        old = mod._db_imports
        try:
            mod._db_imports = None
            with patch.dict(
                "sys.modules",
                {"local_deep_research.database.models": None},
            ):
                mod._db_imports = None
                try:
                    result = mod._get_db_imports()
                    if not result:
                        assert result == {}
                except (ImportError, RuntimeError):
                    pass
        finally:
            mod._db_imports = old

    def test_cached_empty_dict_returned(self):
        """Cached empty dict is returned without re-importing."""
        import local_deep_research.web_search_engines.rate_limiting.tracker as mod

        old = mod._db_imports
        try:
            mod._db_imports = {}
            assert mod._get_db_imports() == {}
        finally:
            mod._db_imports = old


class TestInitNoneSettings:
    """Tests for __init__ when settings return None."""

    def test_none_memory_window_uses_default(self):
        """None memory_window uses default 100."""
        with patch(f"{MODULE}.get_setting_from_snapshot", return_value=None):
            with patch(f"{MODULE}.logger"):
                from local_deep_research.web_search_engines.rate_limiting.tracker import (
                    AdaptiveRateLimitTracker,
                )

                t = AdaptiveRateLimitTracker(programmatic_mode=True)
        assert t.memory_window == 100

    def test_none_exploration_rate_uses_default(self):
        """None exploration_rate uses default 0.1."""
        with patch(f"{MODULE}.get_setting_from_snapshot", return_value=None):
            with patch(f"{MODULE}.logger"):
                from local_deep_research.web_search_engines.rate_limiting.tracker import (
                    AdaptiveRateLimitTracker,
                )

                t = AdaptiveRateLimitTracker(programmatic_mode=True)
        assert t.exploration_rate == 0.1

    def test_none_learning_rate_uses_default(self):
        """None learning_rate uses default 0.3."""
        with patch(f"{MODULE}.get_setting_from_snapshot", return_value=None):
            with patch(f"{MODULE}.logger"):
                from local_deep_research.web_search_engines.rate_limiting.tracker import (
                    AdaptiveRateLimitTracker,
                )

                t = AdaptiveRateLimitTracker(programmatic_mode=True)
        assert t.learning_rate == 0.3

    def test_none_decay_per_day_uses_default(self):
        """None decay_per_day uses default 0.95."""
        with patch(f"{MODULE}.get_setting_from_snapshot", return_value=None):
            with patch(f"{MODULE}.logger"):
                from local_deep_research.web_search_engines.rate_limiting.tracker import (
                    AdaptiveRateLimitTracker,
                )

                t = AdaptiveRateLimitTracker(programmatic_mode=True)
        assert t.decay_per_day == 0.95
