"""Tests for metrics_routes module - Metrics dashboard endpoints."""

from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import patch, MagicMock
from datetime import datetime, UTC

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool

from local_deep_research.database.models import (
    Base,
    ResearchHistory,
    ResearchRating,
    TokenUsage,
)


@contextmanager
def _seeded_metrics_db(*rows):
    """Patch ``get_user_db_session`` with a real in-memory SQLite DB seeded with
    *rows* (model instances).

    Unlike the MagicMock-based patches elsewhere in this file, this runs the
    route's actual SQL, so it can catch join-cardinality bugs (e.g. the
    TokenUsage one-to-many fan-out). A ``StaticPool`` keeps a single shared
    in-memory connection so the seed data and the route see the same DB.
    """
    engine = create_engine(
        "sqlite://",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(engine)
    Session = sessionmaker(bind=engine)

    seed_session = Session()
    seed_session.add_all(rows)
    seed_session.commit()
    seed_session.close()

    @contextmanager
    def _ctx(username, password=None):
        with Session() as session:
            yield session

    with patch(
        "local_deep_research.web.routes.metrics_routes.get_user_db_session",
        side_effect=_ctx,
    ):
        yield


# Metrics routes are registered under /metrics prefix
METRICS_PREFIX = "/metrics"


def _fake_estimate(
    engine_type,
    base_wait,
    min_wait,
    max_wait,
    total_attempts,
    success_rate,
    last_updated=1704067200.0,
):
    """Build a RateLimitEstimate-shaped row for the DB-backed routes."""
    return SimpleNamespace(
        engine_type=engine_type,
        base_wait_seconds=base_wait,
        min_wait_seconds=min_wait,
        max_wait_seconds=max_wait,
        total_attempts=total_attempts,
        success_rate=success_rate,
        last_updated=last_updated,
    )


@contextmanager
def _patch_estimates(module_path, estimates):
    """Patch ``get_user_db_session`` in *module_path* so a route's
    ``session.query(RateLimitEstimate).order_by(...).all()`` returns
    *estimates*."""
    session = MagicMock()
    session.query.return_value.order_by.return_value.all.return_value = (
        estimates
    )

    @contextmanager
    def _ctx(username, password=None):
        yield session

    with patch(f"{module_path}.get_user_db_session", side_effect=_ctx):
        yield session


class TestMetricsDashboard:
    """Tests for /metrics/ endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/")
        assert response.status_code == 302, response.status_code

    def test_returns_page_when_authenticated(self, authenticated_client):
        """Should return metrics page when authenticated."""
        response = authenticated_client.get(f"{METRICS_PREFIX}/")
        assert response.status_code == 200


class TestContextOverflowPage:
    """Tests for /metrics/context-overflow endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/context-overflow")
        assert response.status_code == 302, response.status_code

    def test_returns_page_when_authenticated(self, authenticated_client):
        """Should return context overflow page when authenticated."""
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/context-overflow"
        )
        assert response.status_code == 200


class TestApiMetrics:
    """Tests for /metrics/api/metrics endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/api/metrics")
        assert response.status_code == 401, response.status_code

    def test_returns_metrics_when_authenticated(self, authenticated_client):
        """Should return metrics when authenticated."""
        # This endpoint has many dependencies. Test that it returns valid response.
        response = authenticated_client.get(f"{METRICS_PREFIX}/api/metrics")
        # May return 200 (success) or 500 (deps not mocked) - both acceptable
        assert response.status_code == 200, response.status_code
        if response.status_code == 200:
            data = response.get_json()
            assert data["status"] == "success"
            assert "metrics" in data

    def test_accepts_period_parameter(self, authenticated_client):
        """Should accept period query parameter."""
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/api/metrics?period=7d"
        )
        # May return 200 (success) or 500 (deps not mocked) - both acceptable
        assert response.status_code == 200, response.status_code
        if response.status_code == 200:
            data = response.get_json()
            assert data["period"] == "7d"


class TestApiRateLimitingMetrics:
    """Tests for /metrics/api/rate-limiting endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/api/rate-limiting")
        assert response.status_code == 401, response.status_code

    def test_returns_rate_limiting_data(self, authenticated_client):
        """Should return rate limiting metrics."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.get_rate_limiting_analytics"
        ) as mock_analytics:
            mock_analytics.return_value = {
                "rate_limiting": {
                    "total_attempts": 100,
                    "successful_attempts": 95,
                    "failed_attempts": 5,
                    "success_rate": 95.0,
                }
            }

            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/rate-limiting"
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            assert "data" in data


class TestApiCurrentRateLimits:
    """Tests for /metrics/api/rate-limiting/current endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/api/rate-limiting/current")
        assert response.status_code == 401, response.status_code

    def test_returns_current_limits(self, authenticated_client):
        """Should return current rate limits read from persisted
        RateLimitEstimate rows (the route is now DB-backed, not tracker-
        backed)."""
        estimates = [
            _fake_estimate("pubmed", 1.0, 0.5, 2.0, 100, 0.95),
            _fake_estimate("semantic_scholar", 0.5, 0.2, 1.0, 50, 0.90),
        ]
        with _patch_estimates(
            "local_deep_research.web.routes.metrics_routes", estimates
        ):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/rate-limiting/current"
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            assert "current_limits" in data
            assert len(data["current_limits"]) == 2
            assert data["current_limits"][0]["engine_type"] == "pubmed"
            assert data["current_limits"][0]["success_rate"] == 95.0


class TestApiResearchMetrics:
    """Tests for /metrics/api/metrics/research/<research_id> endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/api/metrics/research/test-id")
        assert response.status_code == 401, response.status_code

    def test_returns_research_metrics(self, authenticated_client):
        """Should return metrics for specific research."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.TokenCounter"
        ) as mock_counter_cls:
            mock_counter = MagicMock()
            mock_counter.get_research_metrics.return_value = {
                "total_tokens": 500,
                "prompt_tokens": 300,
                "completion_tokens": 200,
            }
            mock_counter_cls.return_value = mock_counter

            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/metrics/research/test-id"
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            assert "metrics" in data


class TestApiResearchLinkMetrics:
    """Tests for /metrics/api/metrics/research/<research_id>/links endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(
            f"{METRICS_PREFIX}/api/metrics/research/test-id/links"
        )
        assert response.status_code == 401, response.status_code

    def test_returns_empty_for_no_resources(self, authenticated_client):
        """Should return empty data when no resources exist."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.get_user_db_session"
        ) as mock_session_ctx:
            mock_session = MagicMock()
            mock_session_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_session_ctx.return_value.__exit__ = MagicMock(
                return_value=None
            )

            mock_query = MagicMock()
            mock_query.filter.return_value.all.return_value = []
            mock_session.query.return_value = mock_query

            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/metrics/research/test-id/links"
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            assert data["data"]["total_links"] == 0


class TestApiGetResearchRating:
    """Tests for GET /metrics/api/ratings/<research_id> endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/api/ratings/test-id")
        assert response.status_code == 401, response.status_code

    def test_returns_null_for_no_rating(self, authenticated_client):
        """Should return null rating when none exists."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.get_user_db_session"
        ) as mock_session_ctx:
            mock_session = MagicMock()
            mock_session_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_session_ctx.return_value.__exit__ = MagicMock(
                return_value=None
            )

            mock_query = MagicMock()
            mock_query.filter_by.return_value.first.return_value = None
            mock_session.query.return_value = mock_query

            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/ratings/test-id"
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            assert data["rating"] is None

    def test_returns_existing_rating(self, authenticated_client):
        """Should return existing rating."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.get_user_db_session"
        ) as mock_session_ctx:
            mock_session = MagicMock()
            mock_session_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_session_ctx.return_value.__exit__ = MagicMock(
                return_value=None
            )

            mock_rating = MagicMock()
            mock_rating.rating = 4
            mock_rating.created_at = datetime.now(UTC)
            mock_rating.updated_at = datetime.now(UTC)

            mock_query = MagicMock()
            mock_query.filter_by.return_value.first.return_value = mock_rating
            mock_session.query.return_value = mock_query

            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/ratings/test-id"
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            assert data["rating"] == 4


class TestApiSaveResearchRating:
    """Tests for POST /metrics/api/ratings/<research_id> endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.post(
            f"{METRICS_PREFIX}/api/ratings/test-id", json={"rating": 5}
        )
        assert response.status_code == 401, response.status_code

    def test_validates_rating_range(self, authenticated_client):
        """Should validate rating is between 1 and 5."""
        response = authenticated_client.post(
            f"{METRICS_PREFIX}/api/ratings/test-id", json={"rating": 0}
        )
        assert response.status_code == 400

        response = authenticated_client.post(
            f"{METRICS_PREFIX}/api/ratings/test-id", json={"rating": 6}
        )
        assert response.status_code == 400

    def test_validates_rating_is_integer(self, authenticated_client):
        """Should validate rating is an integer."""
        response = authenticated_client.post(
            f"{METRICS_PREFIX}/api/ratings/test-id", json={"rating": 4.5}
        )
        assert response.status_code == 400

    def test_saves_new_rating(self, authenticated_client):
        """Should save new rating successfully."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.get_user_db_session"
        ) as mock_session_ctx:
            mock_session = MagicMock()
            mock_session_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_session_ctx.return_value.__exit__ = MagicMock(
                return_value=None
            )

            mock_query = MagicMock()
            mock_query.filter_by.return_value.first.return_value = None
            mock_session.query.return_value = mock_query

            response = authenticated_client.post(
                f"{METRICS_PREFIX}/api/ratings/test-id", json={"rating": 5}
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            assert data["rating"] == 5


class TestStarReviewsPage:
    """Tests for /metrics/star-reviews endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/star-reviews")
        assert response.status_code == 302, response.status_code

    def test_returns_page_when_authenticated(self, authenticated_client):
        """Should return star reviews page when authenticated."""
        response = authenticated_client.get(f"{METRICS_PREFIX}/star-reviews")
        assert response.status_code == 200


class TestApiStarReviews:
    """Tests for GET /metrics/api/star-reviews endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/api/star-reviews")
        assert response.status_code in [401, 302]

    def test_returns_star_reviews_data(self, authenticated_client):
        """Should return star reviews analytics data."""
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/api/star-reviews"
        )
        assert response.status_code == 200, (
            f"Expected 200, got {response.status_code}: "
            f"{response.get_json() if response.status_code != 404 else response.data}"
        )
        data = response.get_json()
        assert "overall_stats" in data
        assert "llm_ratings" in data
        assert "search_engine_ratings" in data
        assert "rating_trends" in data
        assert "recent_ratings" in data
        assert "mode_ratings" in data
        assert "quality_dimensions" in data
        assert "recent_feedback" in data

    def test_overall_stats_structure(self, authenticated_client):
        """Should return properly structured overall stats."""
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/api/star-reviews"
        )
        assert response.status_code == 200
        data = response.get_json()
        stats = data["overall_stats"]
        assert "avg_rating" in stats
        assert "total_ratings" in stats
        assert "rating_distribution" in stats
        for i in range(1, 6):
            assert str(i) in stats["rating_distribution"]

    def test_llm_ratings_include_breakdown(self, authenticated_client):
        """LLM ratings should include positive/neutral/negative counts."""
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/api/star-reviews"
        )
        assert response.status_code == 200
        data = response.get_json()
        if data["llm_ratings"]:
            first = data["llm_ratings"][0]
            assert "positive_ratings" in first
            assert "neutral_ratings" in first
            assert "negative_ratings" in first
            assert "satisfaction_rate" in first

    def test_mode_ratings_structure(self, authenticated_client):
        """Mode ratings should be a list with correct fields."""
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/api/star-reviews"
        )
        assert response.status_code == 200
        data = response.get_json()
        assert isinstance(data["mode_ratings"], list)
        if data["mode_ratings"]:
            first = data["mode_ratings"][0]
            assert "mode" in first
            assert "avg_rating" in first
            assert "rating_count" in first
            assert "satisfaction_rate" in first

    def test_quality_dimensions_structure(self, authenticated_client):
        """Quality dimensions should include averages and count."""
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/api/star-reviews"
        )
        assert response.status_code == 200
        dims = response.get_json()["quality_dimensions"]
        assert "avg_accuracy" in dims
        assert "avg_completeness" in dims
        assert "avg_relevance" in dims
        assert "avg_readability" in dims
        assert "dimension_count" in dims

    def test_recent_ratings_include_feedback(self, authenticated_client):
        """Recent ratings should include feedback field."""
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/api/star-reviews"
        )
        assert response.status_code == 200
        data = response.get_json()
        assert isinstance(data["recent_feedback"], list)

    def test_accepts_period_parameter(self, authenticated_client):
        """Should accept period query parameter."""
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/api/star-reviews?period=7d"
        )
        assert response.status_code == 200


class TestApiSaveResearchRatingSubDimensions:
    """Tests for sub-dimension fields in POST /metrics/api/ratings/<id>."""

    def test_saves_rating_with_sub_dimensions(self, authenticated_client):
        """Should save rating with accuracy, completeness, relevance, readability."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.get_user_db_session"
        ) as mock_session_ctx:
            mock_session = MagicMock()
            mock_session_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_session_ctx.return_value.__exit__ = MagicMock(
                return_value=None
            )

            mock_query = MagicMock()
            mock_query.filter_by.return_value.first.return_value = None
            mock_session.query.return_value = mock_query

            response = authenticated_client.post(
                f"{METRICS_PREFIX}/api/ratings/test-id",
                json={
                    "rating": 4,
                    "accuracy": 5,
                    "completeness": 4,
                    "relevance": 5,
                    "readability": 3,
                    "feedback": "Good results",
                },
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            mock_session.add.assert_called_once()
            added_rating = mock_session.add.call_args[0][0]
            assert added_rating.rating == 4
            assert added_rating.accuracy == 5
            assert added_rating.completeness == 4
            assert added_rating.relevance == 5
            assert added_rating.readability == 3
            assert added_rating.feedback == "Good results"

    def test_validates_sub_dimension_range(self, authenticated_client):
        """Should reject sub-dimension values outside 1-5."""
        response = authenticated_client.post(
            f"{METRICS_PREFIX}/api/ratings/test-id",
            json={"rating": 4, "accuracy": 0},
        )
        assert response.status_code == 400

        response = authenticated_client.post(
            f"{METRICS_PREFIX}/api/ratings/test-id",
            json={"rating": 4, "accuracy": 6},
        )
        assert response.status_code == 400

    def test_validates_feedback_is_string(self, authenticated_client):
        """Should reject non-string feedback."""
        response = authenticated_client.post(
            f"{METRICS_PREFIX}/api/ratings/test-id",
            json={"rating": 4, "feedback": 123},
        )
        assert response.status_code == 400

    def test_saves_rating_without_sub_dimensions(self, authenticated_client):
        """Should still save rating when no sub-dimensions provided."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.get_user_db_session"
        ) as mock_session_ctx:
            mock_session = MagicMock()
            mock_session_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_session_ctx.return_value.__exit__ = MagicMock(
                return_value=None
            )

            mock_query = MagicMock()
            mock_query.filter_by.return_value.first.return_value = None
            mock_session.query.return_value = mock_query

            response = authenticated_client.post(
                f"{METRICS_PREFIX}/api/ratings/test-id",
                json={"rating": 3},
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            mock_session.add.assert_called_once()
            added_rating = mock_session.add.call_args[0][0]
            assert added_rating.rating == 3

    def test_updates_existing_rating_with_sub_dimensions(
        self, authenticated_client
    ):
        """Should update an existing rating via setattr when record found."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.get_user_db_session"
        ) as mock_session_ctx:
            mock_session = MagicMock()
            mock_session_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_session_ctx.return_value.__exit__ = MagicMock(
                return_value=None
            )

            existing_rating = MagicMock()
            existing_rating.rating = 2
            mock_query = MagicMock()
            mock_query.filter_by.return_value.first.return_value = (
                existing_rating
            )
            mock_session.query.return_value = mock_query

            response = authenticated_client.post(
                f"{METRICS_PREFIX}/api/ratings/test-id",
                json={
                    "rating": 4,
                    "accuracy": 5,
                    "completeness": 3,
                },
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            assert existing_rating.rating == 4
            assert existing_rating.accuracy == 5
            assert existing_rating.completeness == 3
            mock_session.add.assert_not_called()


class TestCostAnalyticsPage:
    """Tests for /metrics/costs endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/costs")
        assert response.status_code == 302, response.status_code

    def test_returns_page_when_authenticated(self, authenticated_client):
        """Should return cost analytics page when authenticated."""
        response = authenticated_client.get(f"{METRICS_PREFIX}/costs")
        assert response.status_code == 200


class TestLinkAnalyticsPage:
    """Tests for /metrics/links endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/links")
        assert response.status_code == 302, response.status_code

    def test_returns_page_when_authenticated(self, authenticated_client):
        """Should return link analytics page when authenticated."""
        response = authenticated_client.get(f"{METRICS_PREFIX}/links")
        assert response.status_code == 200


class TestApiLinkAnalytics:
    """Tests for /metrics/api/link-analytics endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/api/link-analytics")
        assert response.status_code == 401, response.status_code

    def test_returns_link_analytics(self, authenticated_client):
        """Should return link analytics data."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.get_link_analytics"
        ) as mock_analytics:
            mock_analytics.return_value = {
                "link_analytics": {
                    "top_domains": [],
                    "total_unique_domains": 0,
                    "total_links": 0,
                }
            }

            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/link-analytics"
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            assert "data" in data


class TestApiPricing:
    """Tests for /metrics/api/pricing endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/api/pricing")
        assert response.status_code == 401, response.status_code

    def test_returns_pricing_data(self, authenticated_client):
        """Should return pricing data."""
        response = authenticated_client.get(f"{METRICS_PREFIX}/api/pricing")
        # May return 200 (success) or 500 (deps not available) - both acceptable
        assert response.status_code == 200, response.status_code
        if response.status_code == 200:
            data = response.get_json()
            assert data["status"] == "success"
            assert "pricing" in data


class TestApiModelPricing:
    """Tests for /metrics/api/pricing/<model_name> endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/api/pricing/gpt-4")
        assert response.status_code == 401, response.status_code

    def test_returns_model_pricing(self, authenticated_client):
        """Should return pricing for specific model."""
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/api/pricing/gpt-4"
        )
        # May return 200 (success) or 500 (deps not available) - both acceptable
        assert response.status_code == 200, response.status_code
        if response.status_code == 200:
            data = response.get_json()
            assert data["status"] == "success"
            assert data["model"] == "gpt-4"


class TestApiCostCalculation:
    """Tests for POST /metrics/api/cost-calculation endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.post(
            f"{METRICS_PREFIX}/api/cost-calculation",
            json={
                "model_name": "gpt-4",
                "prompt_tokens": 100,
                "completion_tokens": 50,
            },
        )
        assert response.status_code == 401, response.status_code

    def test_requires_model_name(self, authenticated_client):
        """Should require model_name."""
        response = authenticated_client.post(
            f"{METRICS_PREFIX}/api/cost-calculation",
            json={"prompt_tokens": 100, "completion_tokens": 50},
        )
        assert response.status_code == 400

    def test_calculates_cost(self, authenticated_client):
        """Should calculate cost for tokens."""
        response = authenticated_client.post(
            f"{METRICS_PREFIX}/api/cost-calculation",
            json={
                "model_name": "gpt-4",
                "prompt_tokens": 100,
                "completion_tokens": 50,
            },
        )
        # May return 200 (success) or 500 (deps not available) - both acceptable
        assert response.status_code == 200, response.status_code
        if response.status_code == 200:
            data = response.get_json()
            assert data["status"] == "success"
            assert "total_cost" in data


class TestApiResearchCosts:
    """Tests for /metrics/api/research-costs/<research_id> endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/api/research-costs/test-id")
        assert response.status_code == 401, response.status_code

    def test_returns_no_data_message(self, authenticated_client):
        """Should return message when no token usage data."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.get_user_db_session"
        ) as mock_session_ctx:
            mock_session = MagicMock()
            mock_session_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_session_ctx.return_value.__exit__ = MagicMock(
                return_value=None
            )

            mock_query = MagicMock()
            mock_query.filter.return_value.all.return_value = []
            mock_session.query.return_value = mock_query

            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/research-costs/test-id"
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            assert data["total_cost"] == 0.0


class TestApiCostAnalytics:
    """Tests for /metrics/api/cost-analytics endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/api/cost-analytics")
        assert response.status_code == 401, response.status_code

    def test_returns_cost_analytics(self, authenticated_client):
        """Should return cost analytics data."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.get_user_db_session"
        ) as mock_session_ctx:
            mock_session = MagicMock()
            mock_session_ctx.return_value.__enter__ = MagicMock(
                return_value=mock_session
            )
            mock_session_ctx.return_value.__exit__ = MagicMock(
                return_value=None
            )

            mock_query = MagicMock()
            mock_query.count.return_value = 0
            mock_session.query.return_value = mock_query

            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/cost-analytics"
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            assert "overview" in data


class TestApiDomainClassifications:
    """Tests for /metrics/api/domain-classifications endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(f"{METRICS_PREFIX}/api/domain-classifications")
        assert response.status_code == 401, response.status_code

    def test_returns_classifications(self, authenticated_client):
        """Should return domain classifications."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.DomainClassifier"
        ) as mock_classifier_cls:
            mock_classifier = MagicMock()
            mock_classifier.get_all_classifications.return_value = []
            mock_classifier_cls.return_value = mock_classifier

            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/domain-classifications"
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            assert "classifications" in data


class TestApiClassificationsSummary:
    """Tests for /metrics/api/domain-classifications/summary endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(
            f"{METRICS_PREFIX}/api/domain-classifications/summary"
        )
        assert response.status_code == 401, response.status_code

    def test_returns_summary(self, authenticated_client):
        """Should return classifications summary."""
        with patch(
            "local_deep_research.web.routes.metrics_routes.DomainClassifier"
        ) as mock_classifier_cls:
            mock_classifier = MagicMock()
            mock_classifier.get_categories_summary.return_value = {
                "Academic": 10,
                "News": 5,
            }
            mock_classifier_cls.return_value = mock_classifier

            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/domain-classifications/summary"
            )

            assert response.status_code == 200
            data = response.get_json()
            assert data["status"] == "success"
            assert "summary" in data


class TestApiClassifyDomains:
    """Tests for POST /metrics/api/domain-classifications/classify endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.post(
            f"{METRICS_PREFIX}/api/domain-classifications/classify",
            json={"domain": "example.com"},
        )
        assert response.status_code == 401, response.status_code

    def test_requires_domain_or_batch(self, authenticated_client):
        """Should require domain or batch mode."""
        response = authenticated_client.post(
            f"{METRICS_PREFIX}/api/domain-classifications/classify", json={}
        )
        assert response.status_code == 400


class TestApiClassificationProgress:
    """Tests for /metrics/api/domain-classifications/progress endpoint."""

    def test_requires_authentication(self, client):
        """Should require authentication."""
        response = client.get(
            f"{METRICS_PREFIX}/api/domain-classifications/progress"
        )
        assert response.status_code == 401, response.status_code

    def test_returns_progress(self, authenticated_client):
        """Should return classification progress."""
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/api/domain-classifications/progress"
        )
        # May return 200 (success) or 500 (deps not available) - both acceptable
        assert response.status_code == 200, response.status_code
        if response.status_code == 200:
            data = response.get_json()
            assert data["status"] == "success"
            assert "progress" in data


class TestGetRatingAnalyticsEndpoint:
    """Tests for rating analytics through the API."""

    def test_enhanced_metrics_includes_rating_analytics(
        self, authenticated_client
    ):
        """Should include rating analytics in enhanced metrics response."""
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/api/metrics/enhanced"
        )
        # May return 200 (success) or 500 (deps not available) - both acceptable
        assert response.status_code in [200, 500]
        if response.status_code == 200:
            data = response.get_json()
            assert data["status"] == "success"
            assert "metrics" in data


class TestGetAvailableStrategies:
    """Tests for get_available_strategies helper function."""

    def test_returns_list_of_strategies(self):
        """Should return a list of available strategies."""
        from local_deep_research.web.routes.metrics_routes import (
            get_available_strategies,
        )

        strategies = get_available_strategies()

        assert isinstance(strategies, list)
        assert len(strategies) > 0
        assert all("name" in s and "description" in s for s in strategies)

    def test_includes_common_strategies(self):
        """Should include common strategies."""
        from local_deep_research.web.routes.metrics_routes import (
            get_available_strategies,
        )

        strategies = get_available_strategies()
        strategy_names = [s["name"] for s in strategies]

        assert "source-based" in strategy_names
        assert "focused-iteration" in strategy_names
        assert "topic-organization" in strategy_names


class TestApiResearchJournals:
    """Tests for /metrics/api/journals/research/<id>.

    This is the per-research view added so users can see the journals
    encountered in a single research session (not the cross-research
    aggregate). The endpoint joins through Paper → PaperAppearance →
    ResearchResource and filters by research_id.
    """

    def test_requires_authentication(self, client):
        """Unauthenticated requests should be rejected."""
        response = client.get(
            f"{METRICS_PREFIX}/api/journals/research/some-uuid"
        )
        assert response.status_code == 401, response.status_code

    def test_returns_404_for_unknown_research_id(self, authenticated_client):
        """A research_id that doesn't belong to this user must return 404
        (this is also the ownership check — the per-user DB is the only
        place we look, so unknown IDs are indistinguishable from
        someone else's research and we 404 in both cases).
        """
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/api/journals/research/nonexistent-uuid-12345"
        )
        # 404 is the documented happy-path for unknown ids; 500 is
        # accepted only if the per-user DB tables don't exist yet in
        # the freshly-provisioned test account (the endpoint returns
        # an empty payload in that case via the inspector check).
        assert response.status_code == 404, response.status_code
        if response.status_code == 200:
            data = response.get_json()
            # Empty-table fast path returns the empty-response shape.
            assert data["status"] == "success"
            assert data["summary"]["total_journals"] == 0
        elif response.status_code == 404:
            data = response.get_json()
            assert data["status"] == "error"

    def test_other_users_research_id_returns_404(
        self, authenticated_client, app
    ):
        """Cross-user ownership: user B must not see user A's research.

        Each user has a separate per-user encrypted DB. A research_id
        created by user A will not exist in user B's DB, so the
        endpoint must return the same 404 it would for an unknown id.
        """
        from tests.conftest import generate_unique_test_username

        user_b_name = generate_unique_test_username(prefix="pytest_user_b")
        user_b_password = "TestPass123"

        # Register + log in a second user in a fresh client.
        client_b = app.test_client()
        with client_b:
            reg = client_b.post(
                "/auth/register",
                data={
                    "username": user_b_name,
                    "password": user_b_password,
                    "confirm_password": user_b_password,
                    "acknowledge": "true",
                },
                follow_redirects=False,
            )
            if reg.status_code not in (200, 302):
                pytest.skip(
                    f"Multi-user register unsupported in this env "
                    f"(status={reg.status_code})"
                )
            login = client_b.post(
                "/auth/login",
                data={
                    "username": user_b_name,
                    "password": user_b_password,
                },
                follow_redirects=False,
            )
            if login.status_code not in (200, 302):
                pytest.skip(
                    f"Multi-user login unsupported in this env "
                    f"(status={login.status_code})"
                )

            # User A created no research, but even if they had, user B
            # is isolated by per-user DB — unknown ids must 404 (or
            # 200-empty if the tables aren't provisioned yet in this
            # fresh account).
            response = client_b.get(
                f"{METRICS_PREFIX}/api/journals/research/"
                "some-other-users-research-uuid"
            )
            assert response.status_code in [200, 404, 500]
            if response.status_code == 200:
                data = response.get_json()
                assert data["summary"]["total_journals"] == 0


class TestApiJournalQuality:
    """Tests for /metrics/api/journals — the paginated 212K reference DB
    dashboard endpoint. Exercises auth, pagination clamping, and the
    sort-column allowlist (SQL-injection guard).

    We mock ``get_journal_reference_db`` so the route logic runs without
    triggering the lazy reference-DB build (which would fetch OpenAlex
    over the network and time out in CI).
    """

    def test_requires_authentication(self, client):
        response = client.get(f"{METRICS_PREFIX}/api/journals")
        assert response.status_code == 401, response.status_code

    @staticmethod
    def _mock_ref_db():
        """Build a MagicMock refDB whose .get_journals_page echoes the
        per_page value back to us via the returned total (abused as a
        probe), so we can tell what per_page the route ultimately passed.
        """
        mock_ref = MagicMock()
        mock_ref.available = True
        # (journals, total) — we echo per_page into total to let the
        # per_page-clamp test assert what the route actually used.
        mock_ref.get_journals_page.return_value = ([], 0)
        return mock_ref

    def test_per_page_clamped_to_200(self, authenticated_client):
        """per_page > 200 should clamp to 200 (prevents ad-hoc OOM)."""
        mock_ref = self._mock_ref_db()
        with patch(
            "local_deep_research.journal_quality.db.get_journal_reference_db",
            return_value=mock_ref,
        ):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/journals?per_page=500"
            )
        assert response.status_code == 200
        data = response.get_json()
        pag = data.get("pagination", {})
        assert pag.get("per_page") == 200
        # The DB layer also got the clamped value, not the raw 500.
        kwargs = mock_ref.get_journals_page.call_args.kwargs
        assert kwargs["per_page"] == 200

    def test_sort_injection_defaults_to_safe_column(self, authenticated_client):
        """An attempt at SQL-injection via ``sort`` must not crash the
        route — the DB layer allowlists column names. We verify the
        route passed the raw string through (the allowlist fallback is
        unit-tested on db.py directly).
        """
        mock_ref = self._mock_ref_db()
        bad_sort = "'; DROP TABLE sources; --"
        with patch(
            "local_deep_research.journal_quality.db.get_journal_reference_db",
            return_value=mock_ref,
        ):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/journals?sort={bad_sort}"
            )
        assert response.status_code == 200
        # Route didn't sanitize or reject — it passed through to the DB
        # layer, which is the component that allowlists column names.
        kwargs = mock_ref.get_journals_page.call_args.kwargs
        assert kwargs["sort"] == bad_sort

    def test_page_clamped_to_total_pages(self, authenticated_client):
        """An in-range ``?page`` above ``total_pages`` must be echoed
        back clamped so the UI renders sensible navigation state.
        (Out-of-range ``page`` values are rejected at input validation
        by ``test_page_above_max_returns_400`` below.)
        """
        mock_ref = MagicMock()
        mock_ref.available = True
        # 5 journals total @ 50 per page → total_pages = 1
        mock_ref.get_journals_page.return_value = ([], 5)
        with patch(
            "local_deep_research.journal_quality.db.get_journal_reference_db",
            return_value=mock_ref,
        ):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/journals?page=50"
            )
        assert response.status_code == 200
        pag = response.get_json()["pagination"]
        assert pag["total_count"] == 5
        assert pag["total_pages"] == 1
        assert pag["page"] == 1  # clamped from 50 (within _MAX_PAGE)

    def test_page_above_max_returns_400(self, authenticated_client):
        """A crafted ``?page=10**9`` must be rejected at input validation
        before any DB query runs. The previous behavior silently clamped
        after the SQL executed — this wasted OFFSET scan budget on
        garbage input. Now returns 400 with a helpful message.
        """
        mock_ref = self._mock_ref_db()
        with patch(
            "local_deep_research.journal_quality.db.get_journal_reference_db",
            return_value=mock_ref,
        ):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/journals?page=999999999"
            )
        assert response.status_code == 400
        data = response.get_json()
        assert data["status"] == "error"
        assert "page" in data["message"].lower()
        # DB layer was never called — validation happened first.
        assert mock_ref.get_journals_page.call_count == 0

    def test_score_source_invalid_value_returns_400(self, authenticated_client):
        """``?score_source=garbage`` must be rejected — the writer side
        only emits ``openalex`` / ``doaj`` / ``llm``, so any other value
        can't match a real row and is almost certainly a client bug or
        probing attempt. Rejecting at input validation makes the error
        obvious instead of silently returning an empty result set.
        """
        mock_ref = self._mock_ref_db()
        with patch(
            "local_deep_research.journal_quality.db.get_journal_reference_db",
            return_value=mock_ref,
        ):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/journals?score_source=garbage"
            )
        assert response.status_code == 400
        data = response.get_json()
        assert data["status"] == "error"
        assert "score_source" in data["message"].lower()
        assert mock_ref.get_journals_page.call_count == 0

    def test_score_source_accepts_allowed_values(self, authenticated_client):
        """Each of the three allowlisted ``score_source`` values must
        pass validation and reach the DB layer unchanged.
        """
        mock_ref = self._mock_ref_db()
        for allowed in ("openalex", "doaj", "llm"):
            with patch(
                "local_deep_research.journal_quality.db.get_journal_reference_db",
                return_value=mock_ref,
            ):
                response = authenticated_client.get(
                    f"{METRICS_PREFIX}/api/journals?score_source={allowed}"
                )
            assert response.status_code == 200, f"{allowed} should pass"

    def test_score_source_empty_means_no_filter(self, authenticated_client):
        """An empty ``score_source`` parameter (or omitted) must pass
        validation — it's the default "no filter" case.
        """
        mock_ref = self._mock_ref_db()
        with patch(
            "local_deep_research.journal_quality.db.get_journal_reference_db",
            return_value=mock_ref,
        ):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/journals?score_source="
            )
        assert response.status_code == 200

    def test_page_echoed_when_within_range(self, authenticated_client):
        """A valid ``?page=2`` (within total_pages) must be echoed
        unchanged so the UI's current-page highlight stays correct.
        """
        mock_ref = MagicMock()
        mock_ref.available = True
        # 120 journals @ 50 per page → total_pages = 3
        mock_ref.get_journals_page.return_value = ([], 120)
        with patch(
            "local_deep_research.journal_quality.db.get_journal_reference_db",
            return_value=mock_ref,
        ):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/journals?page=2"
            )
        assert response.status_code == 200
        pag = response.get_json()["pagination"]
        assert pag["total_pages"] == 3
        assert pag["page"] == 2

    def test_page_clamp_handles_empty_result(self, authenticated_client):
        """Empty result set (total=0) must not ZeroDivisionError and
        should report page=1, total_pages=1.
        """
        mock_ref = MagicMock()
        mock_ref.available = True
        mock_ref.get_journals_page.return_value = ([], 0)
        with patch(
            "local_deep_research.journal_quality.db.get_journal_reference_db",
            return_value=mock_ref,
        ):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/journals?page=42"
            )
        assert response.status_code == 200
        pag = response.get_json()["pagination"]
        assert pag["total_count"] == 0
        assert pag["total_pages"] == 1
        assert pag["page"] == 1

    def test_invalid_page_returns_400(self, authenticated_client):
        """A non-integer ``?page=abc`` must surface as 400 (Bad Request),
        not 500. The previous code allowed ``int(...)`` to raise
        ``ValueError`` which the broad outer except mapped to a generic
        500, hiding what was actually a client mistake.
        """
        mock_ref = self._mock_ref_db()
        with patch(
            "local_deep_research.journal_quality.db.get_journal_reference_db",
            return_value=mock_ref,
        ):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/journals?page=abc"
            )
        assert response.status_code == 400
        data = response.get_json()
        assert data["status"] == "error"
        assert "pagination" in data["message"].lower()

    def test_invalid_per_page_returns_400(self, authenticated_client):
        """Same as above for ``?per_page=xyz``."""
        mock_ref = self._mock_ref_db()
        with patch(
            "local_deep_research.journal_quality.db.get_journal_reference_db",
            return_value=mock_ref,
        ):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/journals?per_page=xyz"
            )
        assert response.status_code == 400


class TestApiJournalDataStatus:
    """Tests for /metrics/api/journal-data/status."""

    def test_requires_authentication(self, client):
        response = client.get(f"{METRICS_PREFIX}/api/journal-data/status")
        assert response.status_code == 401, response.status_code

    def test_authenticated_returns_dict(self, authenticated_client):
        response = authenticated_client.get(
            f"{METRICS_PREFIX}/api/journal-data/status"
        )
        # 200 with a JSON dict shape, or 500 if the downloader module
        # isn't importable in this environment.
        assert response.status_code == 200, response.status_code
        if response.status_code == 200:
            data = response.get_json()
            assert isinstance(data, dict)


class TestApiJournalDataDownload:
    """Tests for POST /metrics/api/journal-data/download — the
    rate-limited, CSRF-protected, multi-GB rebuild trigger.
    """

    def test_requires_authentication(self, client):
        response = client.post(
            f"{METRICS_PREFIX}/api/journal-data/download",
            json={"force": False},
        )
        assert response.status_code == 401, response.status_code

    def test_authenticated_post_is_handled(self, authenticated_client):
        """A single authenticated POST either succeeds or is rejected
        by CSRF/rate-limit. Either way it is NOT 401/302 (authentication
        check passed). We mock the downloader to keep the test offline.
        """
        with patch(
            "local_deep_research.journal_quality.downloader.download_journal_data",
            return_value=(False, "mocked"),
        ):
            response = authenticated_client.post(
                f"{METRICS_PREFIX}/api/journal-data/download",
                json={"force": False},
            )
        assert response.status_code not in (401, 302)

    def test_response_does_not_echo_internal_message_on_success(
        self, authenticated_client
    ):
        """CodeQL #7684 regression guard: the JSON body must be built
        from structured counts + developer-authored labels, never from
        the raw `(success, message)` string returned by the downloader.

        We seed the downloader with a message that contains a unique
        canary. If the response body includes the canary, a future
        refactor has reintroduced the taint path.
        """
        canary = "TAINT-CANARY-8f3a9e-do-not-leak"
        internal_msg = f"Fetched 42 OpenAlex sources ... {canary}"

        with (
            patch(
                "local_deep_research.journal_quality.downloader.download_journal_data",
                return_value=(True, internal_msg),
            ),
            patch(
                "local_deep_research.journal_quality.downloader.get_download_state",
                return_value={
                    "counts": {
                        "openalex": 42,
                        "doaj": 7,
                        "jabref": 3,
                        "predatory": 1,
                        "institutions": 5,
                    }
                },
            ),
        ):
            response = authenticated_client.post(
                f"{METRICS_PREFIX}/api/journal-data/download",
                json={"force": True},
            )

        # Skip if CSRF/rate-limit short-circuits (those paths don't
        # reach the response-construction code we're guarding).
        if response.status_code != 200:
            pytest.skip(
                f"Route gated by CSRF/rate-limit (status={response.status_code})"
            )
        body = response.get_data(as_text=True)
        assert canary not in body, (
            "Response leaked the downloader's internal_message — "
            "CodeQL py/stack-trace-exposure has regressed."
        )
        data = response.get_json()
        assert data["success"] is True
        # Structured counts reach the user-facing message.
        assert "42" in data["message"]

    def test_refused_under_private_only_egress_scope(
        self, authenticated_client
    ):
        """Under an offline-for-public scope (PRIVATE_ONLY) the manual
        journal-data download must be refused (403) BEFORE any public HTTP
        fetch — the user opted out of public egress. The downloader is
        intentionally NOT mocked: if the gate works it is never reached.
        """
        mgr = MagicMock()
        mgr.get_setting.side_effect = lambda key, default=None: (
            "private_only" if key == "policy.egress_scope" else default
        )
        with patch(
            "local_deep_research.utilities.db_utils.get_settings_manager",
            return_value=mgr,
        ):
            response = authenticated_client.post(
                f"{METRICS_PREFIX}/api/journal-data/download",
                json={"force": False},
            )
        if response.status_code in (302, 400, 429):
            pytest.skip(
                f"Route gated by CSRF/rate-limit (status={response.status_code})"
            )
        assert response.status_code == 403, response.status_code
        assert response.get_json()["success"] is False

    def test_refused_under_strict_egress_scope(self, authenticated_client):
        """STRICT is likewise an offline-for-public scope here."""
        mgr = MagicMock()
        mgr.get_setting.side_effect = lambda key, default=None: (
            "strict" if key == "policy.egress_scope" else default
        )
        with patch(
            "local_deep_research.utilities.db_utils.get_settings_manager",
            return_value=mgr,
        ):
            response = authenticated_client.post(
                f"{METRICS_PREFIX}/api/journal-data/download",
                json={"force": False},
            )
        if response.status_code in (302, 400, 429):
            pytest.skip(
                f"Route gated by CSRF/rate-limit (status={response.status_code})"
            )
        assert response.status_code == 403, response.status_code

    def test_response_up_to_date_message_when_counts_none(
        self, authenticated_client
    ):
        """When `get_download_state()["counts"]` is None (the downloader
        took the early-return up-to-date branch), the route must emit
        the fixed up-to-date literal — not fall through to an empty
        "Fetched ." string.
        """
        with (
            patch(
                "local_deep_research.journal_quality.downloader.download_journal_data",
                return_value=(True, "Journal data is already up to date"),
            ),
            patch(
                "local_deep_research.journal_quality.downloader.get_download_state",
                return_value={"counts": None},
            ),
        ):
            response = authenticated_client.post(
                f"{METRICS_PREFIX}/api/journal-data/download",
                json={"force": False},
            )

        if response.status_code != 200:
            pytest.skip(
                f"Route gated by CSRF/rate-limit (status={response.status_code})"
            )
        data = response.get_json()
        assert data["success"] is True
        assert "up to date" in data["message"].lower()


def _token_usage(research_id, model_name="gpt-4", ts=None, search_engine=None):
    """Build a minimally-valid TokenUsage row (one per simulated LLM call)."""
    return TokenUsage(
        research_id=research_id,
        model_provider="openai",
        model_name=model_name,
        prompt_tokens=1,
        completion_tokens=1,
        total_tokens=2,
        timestamp=ts or datetime.now(UTC),
        search_engine_selected=search_engine,
    )


class TestApiStarReviewsFanOut:
    """Regression tests for the TokenUsage one-to-many fan-out (PR #3804).

    A research session has many ``token_usage`` rows (one per LLM call). The
    star-reviews endpoint used to join TokenUsage directly, which multiplied
    rating rows: ``recent_ratings`` (limited to 20) returned the same rating
    duplicated, and the LLM/search-engine breakdown counts were inflated by the
    number of LLM calls. These tests seed one rating plus several TokenUsage
    rows and assert each rating is counted exactly once.
    """

    def test_recent_ratings_not_duplicated_by_token_usage(
        self, authenticated_client
    ):
        now = datetime.now(UTC)
        rows = [
            ResearchHistory(
                id="r1",
                query="What is X?",
                mode="standard",
                status="completed",
                created_at=now.isoformat(),
            ),
            ResearchRating(research_id="r1", rating=5, created_at=now),
            _token_usage("r1", ts=now),
            _token_usage("r1", ts=now),
            _token_usage("r1", ts=now),
        ]
        with _seeded_metrics_db(*rows):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/star-reviews?period=all"
            )
        assert response.status_code == 200, response.get_json()
        recent = response.get_json()["recent_ratings"]
        assert len(recent) == 1, (
            f"fan-out: expected 1 unique rating, got {len(recent)}"
        )
        assert recent[0]["research_id"] == "r1"
        assert recent[0]["query"] == "What is X?"
        assert recent[0]["mode"] == "standard"
        assert recent[0]["llm_model"] == "gpt-4"

    def test_llm_breakdown_counts_not_inflated_by_token_usage(
        self, authenticated_client
    ):
        now = datetime.now(UTC)
        rows = [
            ResearchRating(research_id="r1", rating=5, created_at=now),
            _token_usage("r1", ts=now),
            _token_usage("r1", ts=now),
            _token_usage("r1", ts=now),
            _token_usage("r1", ts=now),
        ]
        with _seeded_metrics_db(*rows):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/star-reviews?period=all"
            )
        assert response.status_code == 200, response.get_json()
        gpt4 = [
            r
            for r in response.get_json()["llm_ratings"]
            if r["model"] == "gpt-4"
        ]
        assert len(gpt4) == 1
        assert gpt4[0]["rating_count"] == 1, "fan-out inflated rating_count"
        assert gpt4[0]["positive_ratings"] == 1, "fan-out inflated positives"
        assert gpt4[0]["avg_rating"] == 5.0

    def test_search_engine_counts_not_inflated_by_token_usage(
        self, authenticated_client
    ):
        now = datetime.now(UTC)
        rows = [
            ResearchRating(research_id="r1", rating=4, created_at=now),
            _token_usage("r1", ts=now, search_engine="searxng"),
            _token_usage("r1", ts=now, search_engine="searxng"),
            _token_usage("r1", ts=now, search_engine="searxng"),
        ]
        with _seeded_metrics_db(*rows):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/star-reviews?period=all"
            )
        assert response.status_code == 200, response.get_json()
        searxng = [
            r
            for r in response.get_json()["search_engine_ratings"]
            if r["search_engine"] == "searxng"
        ]
        assert len(searxng) == 1
        assert searxng[0]["rating_count"] == 1, "fan-out inflated count"
        assert searxng[0]["positive_ratings"] == 1

    def test_llm_breakdown_counts_with_multiple_models(
        self, authenticated_client
    ):
        """A rating on a research that used two models counts once per model
        (the semantic the distinct subquery exists for) — never duplicated
        within a model, and the recent list shows the rating once."""
        now = datetime.now(UTC)
        rows = [
            ResearchRating(research_id="r1", rating=5, created_at=now),
            _token_usage("r1", model_name="gpt-4", ts=now),
            _token_usage("r1", model_name="gpt-4", ts=now),
            _token_usage("r1", model_name="claude", ts=now),
            _token_usage("r1", model_name="claude", ts=now),
            _token_usage("r1", model_name="claude", ts=now),
        ]
        with _seeded_metrics_db(*rows):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/star-reviews?period=all"
            )
        assert response.status_code == 200, response.get_json()
        data = response.get_json()
        by_model = {r["model"]: r for r in data["llm_ratings"]}
        assert by_model["gpt-4"]["rating_count"] == 1
        assert by_model["claude"]["rating_count"] == 1
        assert by_model["gpt-4"]["positive_ratings"] == 1
        assert by_model["claude"]["positive_ratings"] == 1
        assert len(data["recent_ratings"]) == 1

    def test_search_engine_unknown_bucket_excludes_real_engine_ratings(
        self, authenticated_client
    ):
        """A research that used a real engine must not ALSO appear in the
        'Unknown' bucket because of its non-search (NULL-engine) LLM-call
        rows. 'Unknown' should hold only ratings with no recorded engine."""
        now = datetime.now(UTC)
        rows = [
            # r1 used searxng, plus non-search LLM calls (engine NULL)
            ResearchRating(research_id="r1", rating=5, created_at=now),
            _token_usage("r1", ts=now, search_engine="searxng"),
            _token_usage("r1", ts=now, search_engine=None),
            _token_usage("r1", ts=now, search_engine=None),
            # r2 made only non-search LLM calls -> belongs in 'Unknown' once
            ResearchRating(research_id="r2", rating=3, created_at=now),
            _token_usage("r2", ts=now, search_engine=None),
        ]
        with _seeded_metrics_db(*rows):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/star-reviews?period=all"
            )
        assert response.status_code == 200, response.get_json()
        buckets = {
            r["search_engine"]: r
            for r in response.get_json()["search_engine_ratings"]
        }
        # r1 counts under searxng only
        assert buckets["searxng"]["rating_count"] == 1
        # 'Unknown' holds only r2 (the engine-less research), not r1
        assert buckets["Unknown"]["rating_count"] == 1
        assert buckets["Unknown"]["avg_rating"] == 3.0

    def test_search_engine_multi_engine_research_counts_once_per_engine(
        self, authenticated_client
    ):
        """A research that used two real engines counts once under EACH engine
        (the distinct (research_id, engine) semantic) — not duplicated within an
        engine, and its NULL-engine rows do not spawn an 'Unknown' bucket."""
        now = datetime.now(UTC)
        rows = [
            ResearchRating(research_id="r1", rating=5, created_at=now),
            _token_usage("r1", ts=now, search_engine="searxng"),
            _token_usage("r1", ts=now, search_engine="searxng"),
            _token_usage("r1", ts=now, search_engine="arxiv"),
            _token_usage("r1", ts=now, search_engine=None),
        ]
        with _seeded_metrics_db(*rows):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/star-reviews?period=all"
            )
        assert response.status_code == 200, response.get_json()
        buckets = {
            r["search_engine"]: r
            for r in response.get_json()["search_engine_ratings"]
        }
        assert buckets["searxng"]["rating_count"] == 1
        assert buckets["arxiv"]["rating_count"] == 1
        # the NULL-engine row must not create an 'Unknown' bucket here
        assert "Unknown" not in buckets

    def test_llm_unknown_buckets_merge(self, authenticated_client):
        """All "missing model" sentinels collapse into a single 'Unknown' bucket
        — empty string, lowercase 'unknown', uppercase 'UNKNOWN', whitespace, and
        no-token (NULL) — counted once each, while real model names keep casing.

        r1 carries BOTH '' and 'unknown' token rows: normalization must happen
        inside the DISTINCT subquery so its single rating is counted ONCE, not
        once per sentinel variant (the merged bucket must total 4, not 5).
        """
        now = datetime.now(UTC)
        rows = [
            # r1: two distinct sentinels on ONE research -> must count once
            ResearchRating(research_id="r1", rating=5, created_at=now),
            _token_usage("r1", model_name="", ts=now),
            _token_usage("r1", model_name="unknown", ts=now),
            # r2: uppercase sentinel (exercises lower())
            ResearchRating(research_id="r2", rating=4, created_at=now),
            _token_usage("r2", model_name="UNKNOWN", ts=now),
            # r3: whitespace-only (exercises trim())
            ResearchRating(research_id="r3", rating=3, created_at=now),
            _token_usage("r3", model_name="   ", ts=now),
            # r4: no token rows -> NULL model via the outerjoin
            ResearchRating(research_id="r4", rating=2, created_at=now),
            # r5: a real model -> its own bucket, casing preserved
            ResearchRating(research_id="r5", rating=5, created_at=now),
            _token_usage("r5", model_name="GPT-4", ts=now),
        ]
        with _seeded_metrics_db(*rows):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/star-reviews?period=all"
            )
        assert response.status_code == 200, response.get_json()
        buckets = {r["model"]: r for r in response.get_json()["llm_ratings"]}
        # no sentinel variant survives as its own bucket
        for sentinel in ("", "unknown", "UNKNOWN", "   "):
            assert sentinel not in buckets
        # r1..r4 merge into Unknown, each counted once (r1's two sentinels = 1)
        assert buckets["Unknown"]["rating_count"] == 4
        assert buckets["Unknown"]["avg_rating"] == 3.5  # (5+4+3+2)/4
        assert buckets["Unknown"]["positive_ratings"] == 2  # ratings 5 and 4
        # real model keeps its original casing and stays separate
        assert "GPT-4" in buckets
        assert buckets["GPT-4"]["rating_count"] == 1

    def test_search_engine_empty_string_merges_into_unknown(
        self, authenticated_client
    ):
        """An empty-string search_engine_selected is treated as 'no engine' and
        merges into 'Unknown' rather than forming a separate '' bucket."""
        now = datetime.now(UTC)
        rows = [
            ResearchRating(research_id="r1", rating=4, created_at=now),
            _token_usage("r1", ts=now, search_engine=""),
            ResearchRating(research_id="r2", rating=2, created_at=now),
            _token_usage("r2", ts=now, search_engine=None),
            # whitespace-only engine (exercises trim()) -> also Unknown
            ResearchRating(research_id="r3", rating=3, created_at=now),
            _token_usage("r3", ts=now, search_engine="   "),
        ]
        with _seeded_metrics_db(*rows):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/star-reviews?period=all"
            )
        assert response.status_code == 200, response.get_json()
        buckets = {
            r["search_engine"]: r
            for r in response.get_json()["search_engine_ratings"]
        }
        assert "" not in buckets
        assert "   " not in buckets
        # r1 (''), r2 (NULL), r3 (whitespace) all land in 'Unknown'
        assert buckets["Unknown"]["rating_count"] == 3


class TestApiStarReviewsQualityDimensions:
    """Quality radar must not be suppressed when only some dimensions are set."""

    def test_radar_not_hidden_when_only_some_dimensions_set(
        self, authenticated_client
    ):
        now = datetime.now(UTC)
        # accuracy is NULL but completeness is set: the radar previously keyed
        # its empty-state on count(accuracy) and hid real data.
        rows = [
            ResearchRating(
                research_id="r1", rating=4, completeness=4, created_at=now
            ),
        ]
        with _seeded_metrics_db(*rows):
            response = authenticated_client.get(
                f"{METRICS_PREFIX}/api/star-reviews?period=all"
            )
        assert response.status_code == 200, response.get_json()
        dims = response.get_json()["quality_dimensions"]
        assert dims["dimension_count"] == 1, "radar would be hidden"
        assert dims["avg_accuracy"] is None
        assert dims["avg_completeness"] == 4.0
        assert dims["dimension_counts"]["accuracy"] == 0
        assert dims["dimension_counts"]["completeness"] == 1


class TestApiSaveResearchRatingValidation:
    """Validation hardening for POST /metrics/api/ratings/<id> (PR #3804)."""

    def test_rejects_boolean_rating(self, authenticated_client):
        """``True`` is an int in Python but must not pass the 1-5 check."""
        response = authenticated_client.post(
            f"{METRICS_PREFIX}/api/ratings/test-id", json={"rating": True}
        )
        assert response.status_code == 400
        assert "between 1 and 5" in response.get_json()["message"]

    def test_rejects_boolean_sub_dimension(self, authenticated_client):
        response = authenticated_client.post(
            f"{METRICS_PREFIX}/api/ratings/test-id",
            json={"rating": 4, "accuracy": True},
        )
        assert response.status_code == 400
        assert "accuracy must be an integer" in response.get_json()["message"]

    def test_rejects_overlong_feedback(self, authenticated_client):
        response = authenticated_client.post(
            f"{METRICS_PREFIX}/api/ratings/test-id",
            json={"rating": 4, "feedback": "x" * 10001},
        )
        assert response.status_code == 400
        assert "10000" in response.get_json()["message"]
