"""Unit tests for get_strategy_analytics and get_rate_limiting_analytics.

These test the aggregation logic directly (mocking DB), not through HTTP endpoints.
Production code: src/local_deep_research/web/routes/metrics_routes.py
"""

from unittest.mock import MagicMock, Mock, patch

import pytest

from local_deep_research.web.routes.metrics_routes import (
    get_rate_limiting_analytics,
    get_strategy_analytics,
)

_MOD = "local_deep_research.web.routes.metrics_routes"


def _mock_session_ctx(mock_ctx, session):
    """Wire up get_user_db_session mock as context manager."""
    mock_ctx.return_value.__enter__ = Mock(return_value=session)
    mock_ctx.return_value.__exit__ = Mock(return_value=False)


# ---------------------------------------------------------------------------
# get_strategy_analytics
# ---------------------------------------------------------------------------


class TestGetStrategyAnalytics:
    """Tests for get_strategy_analytics aggregation logic."""

    @patch(f"{_MOD}.flask_session", {})
    @patch(f"{_MOD}.get_user_db_session")
    def test_no_username_returns_error(self, mock_ctx):
        result = get_strategy_analytics(username=None)
        analytics = result["strategy_analytics"]
        assert analytics["error"] == "No user session"
        assert analytics["total_research"] == 0
        assert analytics["strategy_usage"] == []

    @patch(f"{_MOD}.get_user_db_session")
    def test_empty_strategy_table(self, mock_ctx):
        session = MagicMock()
        session.query.return_value.count.return_value = 0
        _mock_session_ctx(mock_ctx, session)

        result = get_strategy_analytics(username="alice")
        analytics = result["strategy_analytics"]
        assert "message" in analytics
        assert "not yet available" in analytics["message"]
        assert analytics["total_research"] == 0

    @patch(f"{_MOD}.get_user_db_session")
    def test_single_strategy(self, mock_ctx):
        session = MagicMock()
        # .count() for initial check
        session.query.return_value.count.return_value = 1

        # Chain for grouped query
        query_chain = session.query.return_value
        query_chain.filter.return_value = query_chain
        query_chain.group_by.return_value.order_by.return_value.all.return_value = [
            ("quick", 5),
        ]
        # total_query.count()
        query_chain.filter.return_value.count.return_value = 5
        _mock_session_ctx(mock_ctx, session)

        result = get_strategy_analytics(period="30d", username="alice")
        analytics = result["strategy_analytics"]
        assert analytics["most_popular_strategy"] == "quick"
        assert len(analytics["strategy_usage"]) == 1
        assert analytics["strategy_usage"][0]["percentage"] == 100.0
        assert analytics["strategy_usage"][0]["count"] == 5
        assert analytics["total_research"] == 5

    @patch(f"{_MOD}.get_user_db_session")
    def test_multiple_strategies_sorted_desc(self, mock_ctx):
        session = MagicMock()
        session.query.return_value.count.return_value = 3

        query_chain = session.query.return_value
        query_chain.filter.return_value = query_chain
        query_chain.group_by.return_value.order_by.return_value.all.return_value = [
            ("deep", 6),
            ("quick", 4),
        ]
        query_chain.filter.return_value.count.return_value = 10
        _mock_session_ctx(mock_ctx, session)

        result = get_strategy_analytics(period="30d", username="alice")
        analytics = result["strategy_analytics"]
        assert analytics["most_popular_strategy"] == "deep"
        assert len(analytics["strategy_usage"]) == 2
        # Percentages should sum to 100
        total_pct = sum(s["percentage"] for s in analytics["strategy_usage"])
        assert total_pct == pytest.approx(100.0, abs=0.2)

    @patch(f"{_MOD}.get_user_db_session")
    def test_period_all_no_time_filter(self, mock_ctx):
        """period='all' → days=None → no time filter."""
        session = MagicMock()
        # Initial count
        base_query = MagicMock()
        session.query.return_value = base_query
        base_query.count.return_value = 1
        # No filter should be called for "all"
        base_query.group_by.return_value.order_by.return_value.all.return_value = [
            ("quick", 1),
        ]
        _mock_session_ctx(mock_ctx, session)

        result = get_strategy_analytics(period="all", username="alice")
        analytics = result["strategy_analytics"]
        assert analytics["total_research"] == 1
        # filter should NOT be called (no time cutoff)
        base_query.filter.assert_not_called()

    @patch(f"{_MOD}.get_user_db_session")
    def test_period_7d_returns_valid_result(self, mock_ctx):
        session = MagicMock()
        session.query.return_value.count.return_value = 1
        query_chain = session.query.return_value
        query_chain.filter.return_value = query_chain
        query_chain.group_by.return_value.order_by.return_value.all.return_value = []
        query_chain.filter.return_value.count.return_value = 0
        _mock_session_ctx(mock_ctx, session)

        result = get_strategy_analytics(period="7d", username="alice")
        assert "strategy_analytics" in result
        assert result["strategy_analytics"]["strategy_usage"] == []

    @patch(f"{_MOD}.get_user_db_session")
    def test_unknown_period_defaults_to_30(self, mock_ctx):
        session = MagicMock()
        session.query.return_value.count.return_value = 1
        query_chain = session.query.return_value
        query_chain.filter.return_value = query_chain
        query_chain.group_by.return_value.order_by.return_value.all.return_value = []
        query_chain.filter.return_value.count.return_value = 0
        _mock_session_ctx(mock_ctx, session)

        # Should not raise, defaults to 30 days
        result = get_strategy_analytics(period="999d", username="alice")
        assert "strategy_analytics" in result
        # No error key means it ran successfully with default period
        assert "error" not in result["strategy_analytics"]

    @patch(f"{_MOD}.get_user_db_session")
    def test_exception_returns_fallback(self, mock_ctx):
        mock_ctx.return_value.__enter__ = Mock(
            side_effect=RuntimeError("DB down")
        )
        mock_ctx.return_value.__exit__ = Mock(return_value=False)

        result = get_strategy_analytics(username="alice")
        analytics = result["strategy_analytics"]
        assert analytics["error"] == "Failed to retrieve strategy data"
        assert analytics["total_research"] == 0

    @patch(f"{_MOD}.get_user_db_session")
    def test_strategy_distribution_matches_usage(self, mock_ctx):
        session = MagicMock()
        session.query.return_value.count.return_value = 2
        query_chain = session.query.return_value
        query_chain.filter.return_value = query_chain
        query_chain.group_by.return_value.order_by.return_value.all.return_value = [
            ("deep", 3),
            ("quick", 7),
        ]
        query_chain.filter.return_value.count.return_value = 10
        _mock_session_ctx(mock_ctx, session)

        result = get_strategy_analytics(period="30d", username="alice")
        analytics = result["strategy_analytics"]
        assert analytics["strategy_distribution"] == {"deep": 3, "quick": 7}

    @patch(f"{_MOD}.get_user_db_session")
    def test_zero_total_research_no_division_error(self, mock_ctx):
        """When total_research is 0 after time filter, percentages should be 0."""
        session = MagicMock()
        session.query.return_value.count.return_value = 1  # has records overall
        query_chain = session.query.return_value
        query_chain.filter.return_value = query_chain
        # But after time filter, nothing found - yet group_by returns empty
        query_chain.group_by.return_value.order_by.return_value.all.return_value = []
        query_chain.filter.return_value.count.return_value = 0
        _mock_session_ctx(mock_ctx, session)

        result = get_strategy_analytics(period="7d", username="alice")
        analytics = result["strategy_analytics"]
        assert analytics["total_research"] == 0
        assert analytics["strategy_usage"] == []

    @patch(f"{_MOD}.get_user_db_session")
    def test_total_research_with_strategy_sums_counts(self, mock_ctx):
        session = MagicMock()
        session.query.return_value.count.return_value = 5
        query_chain = session.query.return_value
        query_chain.filter.return_value = query_chain
        query_chain.group_by.return_value.order_by.return_value.all.return_value = [
            ("deep", 3),
            ("quick", 2),
        ]
        query_chain.filter.return_value.count.return_value = 5
        _mock_session_ctx(mock_ctx, session)

        result = get_strategy_analytics(period="30d", username="alice")
        analytics = result["strategy_analytics"]
        assert analytics["total_research_with_strategy"] == 5


# ---------------------------------------------------------------------------
# get_rate_limiting_analytics
# ---------------------------------------------------------------------------


def _make_estimate(
    engine_type,
    success_rate,
    base_wait=1.0,
    min_wait=0.5,
    max_wait=5.0,
    total_attempts=10,
    last_updated=1000.0,
):
    """Create a mock RateLimitEstimate."""
    e = Mock()
    e.engine_type = engine_type
    e.success_rate = success_rate
    e.base_wait_seconds = base_wait
    e.min_wait_seconds = min_wait
    e.max_wait_seconds = max_wait
    e.total_attempts = total_attempts
    e.last_updated = last_updated
    return e


class TestGetRateLimitingAnalytics:
    """Tests for get_rate_limiting_analytics aggregation logic."""

    @patch(f"{_MOD}.flask_session", {})
    @patch(f"{_MOD}.get_user_db_session")
    def test_no_username_returns_error(self, mock_ctx):
        result = get_rate_limiting_analytics(username=None)
        rl = result["rate_limiting"]
        assert rl["error"] == "No user session"
        assert rl["total_attempts"] == 0
        assert rl["engine_stats"] == []

    @patch(f"{_MOD}.get_user_db_session")
    def test_no_attempts_all_zeros(self, mock_ctx):
        session = MagicMock()
        query = session.query.return_value
        query.filter.return_value = query
        query.all.return_value = []
        _mock_session_ctx(mock_ctx, session)

        result = get_rate_limiting_analytics(period="30d", username="alice")
        rl = result["rate_limiting"]
        assert rl["total_attempts"] == 0
        assert rl["successful_attempts"] == 0
        assert rl["success_rate"] == 0
        assert rl["avg_wait_time"] == 0
        assert rl["engine_stats"] == []

    @patch(f"{_MOD}.get_user_db_session")
    @patch("time.time", return_value=1_000_000.0)
    def test_mixed_success_failure(self, mock_time, mock_ctx):
        """total/successful/failed counts and success_rate are derived from
        an estimate's recent-window total_attempts and stored success_rate."""
        session = MagicMock()
        query = session.query.return_value
        query.filter.return_value = query
        query.all.return_value = [
            _make_estimate(
                "google", success_rate=2 / 3, total_attempts=3, base_wait=0.6
            ),
        ]
        _mock_session_ctx(mock_ctx, session)

        result = get_rate_limiting_analytics(period="30d", username="alice")
        rl = result["rate_limiting"]
        assert rl["total_attempts"] == 3
        assert rl["successful_attempts"] == 2  # round(3 * 2/3) == 2
        assert rl["failed_attempts"] == 1
        assert rl["success_rate"] == pytest.approx(66.67, abs=0.1)
        # avg_wait_time is the learned base wait (no per-attempt history).
        assert rl["avg_wait_time"] == pytest.approx(0.6, abs=0.01)

    @patch(f"{_MOD}.get_user_db_session")
    @patch("time.time", return_value=1_000_000.0)
    def test_rate_limit_events_always_zero(self, mock_time, mock_ctx):
        """rate_limit_events (RateLimitError-specific failures) can't be
        reconstructed from estimates — the per-attempt error_type is gone —
        so it is always reported as 0, never crashing."""
        session = MagicMock()
        query = session.query.return_value
        query.filter.return_value = query
        query.all.return_value = [_make_estimate("bing", success_rate=0.3)]
        _mock_session_ctx(mock_ctx, session)

        result = get_rate_limiting_analytics(period="30d", username="alice")
        assert result["rate_limiting"]["rate_limit_events"] == 0

    @patch(f"{_MOD}.get_user_db_session")
    @patch("time.time", return_value=1_000_000.0)
    def test_avg_successful_wait_is_learned_base_wait(
        self, mock_time, mock_ctx
    ):
        """No raw per-attempt waits exist, so avg_successful_wait mirrors the
        mean learned base wait (== avg_wait_time)."""
        session = MagicMock()
        query = session.query.return_value
        query.filter.return_value = query
        query.all.return_value = [
            _make_estimate("google", success_rate=0.9, base_wait=0.4),
            _make_estimate("bing", success_rate=0.9, base_wait=0.6),
        ]
        _mock_session_ctx(mock_ctx, session)

        result = get_rate_limiting_analytics(period="30d", username="alice")
        rl = result["rate_limiting"]
        # mean of the two base waits: (0.4 + 0.6) / 2 == 0.5
        assert rl["avg_wait_time"] == pytest.approx(0.5, abs=0.01)
        assert rl["avg_successful_wait"] == rl["avg_wait_time"]

    @patch(f"{_MOD}.get_user_db_session")
    @patch("time.time", return_value=1_000_000.0)
    def test_engine_status_healthy_with_estimate(self, mock_time, mock_ctx):
        """estimate.success_rate > 0.8 → 'healthy'."""

        session = MagicMock()
        query = session.query.return_value
        query.filter.return_value = query
        query.all.return_value = [_make_estimate("google", success_rate=0.9)]
        _mock_session_ctx(mock_ctx, session)

        result = get_rate_limiting_analytics(period="30d", username="alice")
        engine_stats = result["rate_limiting"]["engine_stats"]
        assert len(engine_stats) == 1
        assert engine_stats[0]["status"] == "healthy"
        assert result["rate_limiting"]["healthy_engines"] == 1

    @patch(f"{_MOD}.get_user_db_session")
    @patch("time.time", return_value=1_000_000.0)
    def test_engine_status_degraded_with_estimate(self, mock_time, mock_ctx):
        """0.5 < estimate.success_rate <= 0.8 → 'degraded'."""

        session = MagicMock()
        query = session.query.return_value
        query.filter.return_value = query
        query.all.return_value = [_make_estimate("google", success_rate=0.7)]
        _mock_session_ctx(mock_ctx, session)

        result = get_rate_limiting_analytics(period="30d", username="alice")
        assert (
            result["rate_limiting"]["engine_stats"][0]["status"] == "degraded"
        )
        assert result["rate_limiting"]["degraded_engines"] == 1

    @patch(f"{_MOD}.get_user_db_session")
    @patch("time.time", return_value=1_000_000.0)
    def test_engine_status_poor_with_estimate(self, mock_time, mock_ctx):
        """estimate.success_rate <= 0.5 → 'poor'."""

        session = MagicMock()
        query = session.query.return_value
        query.filter.return_value = query
        query.all.return_value = [_make_estimate("google", success_rate=0.3)]
        _mock_session_ctx(mock_ctx, session)

        result = get_rate_limiting_analytics(period="30d", username="alice")
        assert result["rate_limiting"]["engine_stats"][0]["status"] == "poor"
        assert result["rate_limiting"]["poor_engines"] == 1

    @patch(f"{_MOD}.get_user_db_session")
    @patch("time.time", return_value=1_000_000.0)
    def test_status_thresholds_are_strict(self, mock_time, mock_ctx):
        """Health thresholds are strict (>): success_rate 0.8 is degraded
        (not healthy) and 0.5 is poor (not degraded)."""
        session = MagicMock()
        query = session.query.return_value
        query.filter.return_value = query
        query.all.return_value = [
            _make_estimate("edge_high", success_rate=0.8),
            _make_estimate("edge_low", success_rate=0.5),
        ]
        _mock_session_ctx(mock_ctx, session)

        result = get_rate_limiting_analytics(period="30d", username="alice")
        engines = {
            s["engine"]: s for s in result["rate_limiting"]["engine_stats"]
        }
        assert engines["edge_high"]["status"] == "degraded"
        assert engines["edge_low"]["status"] == "poor"

    @patch(f"{_MOD}.get_user_db_session")
    @patch("time.time", return_value=1_000_000.0)
    def test_multiple_engines_aggregated(self, mock_time, mock_ctx):
        session = MagicMock()
        query = session.query.return_value
        query.filter.return_value = query
        query.all.return_value = [
            _make_estimate("google", success_rate=1.0, total_attempts=4),
            _make_estimate("bing", success_rate=0.5, total_attempts=4),
        ]
        _mock_session_ctx(mock_ctx, session)

        result = get_rate_limiting_analytics(period="30d", username="alice")
        rl = result["rate_limiting"]
        assert rl["total_engines_tracked"] == 2
        engines = {s["engine"]: s for s in rl["engine_stats"]}
        assert engines["google"]["recent_success_rate"] == 100.0
        assert engines["bing"]["recent_success_rate"] == 50.0

    @patch(f"{_MOD}.get_user_db_session")
    @patch("time.time", return_value=1_000_000.0)
    def test_period_all_skips_recency_filter(self, mock_time, mock_ctx):
        """period='all' → cutoff_time=0 → no last_updated filter applied."""

        session = MagicMock()
        query = session.query.return_value
        query.filter.return_value = query
        query.all.return_value = []
        _mock_session_ctx(mock_ctx, session)

        result = get_rate_limiting_analytics(period="all", username="alice")
        assert "rate_limiting" in result
        # No recency cutoff means the estimates query is never filtered.
        query.filter.assert_not_called()

    @patch(f"{_MOD}.get_user_db_session")
    @patch("time.time", return_value=1_000_000.0)
    def test_time_window_applies_last_updated_cutoff(self, mock_time, mock_ctx):
        """A bounded period filters estimates by last_updated >= cutoff."""

        session = MagicMock()
        query = session.query.return_value
        query.filter.return_value = query
        query.all.return_value = []
        _mock_session_ctx(mock_ctx, session)

        get_rate_limiting_analytics(period="7d", username="alice")

        query.filter.assert_called_once()
        # The filter is `RateLimitEstimate.last_updated >= now - 7d`; verify
        # both the column and the bound cutoff value.
        criterion = query.filter.call_args.args[0]
        assert "last_updated" in str(criterion)
        expected_cutoff = 1_000_000.0 - (7 * 24 * 3600)
        assert criterion.right.value == pytest.approx(expected_cutoff)

    @patch(f"{_MOD}.get_user_db_session")
    def test_exception_returns_fallback(self, mock_ctx):
        mock_ctx.return_value.__enter__ = Mock(
            side_effect=RuntimeError("DB down")
        )
        mock_ctx.return_value.__exit__ = Mock(return_value=False)

        result = get_rate_limiting_analytics(username="alice")
        rl = result["rate_limiting"]
        assert "error" in rl
        assert rl["total_attempts"] == 0
        assert rl["engine_stats"] == []
