"""
Comprehensive coverage tests for metrics_routes.py.

Tests cover:
- Helper functions: _extract_domain, get_rating_analytics, get_link_analytics,
  get_strategy_analytics, get_rate_limiting_analytics, get_available_strategies
- All route handlers via Flask test_client with mocked DB sessions
- Error handling paths, edge cases, and branch conditions
"""

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

import pytest
from flask import Blueprint, Flask

from local_deep_research.web.routes.metrics_routes import (
    _extract_domain,
    get_available_strategies,
    get_rating_analytics,
    get_link_analytics,
    get_strategy_analytics,
    get_rate_limiting_analytics,
    metrics_bp,
)


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


def _auth_session(client, username="testuser"):
    """Inject an authenticated session."""
    with client.session_transaction() as sess:
        sess["username"] = username


@contextmanager
def _mock_db_session(mock_session=None):
    """Context manager that returns a mock DB session."""
    if mock_session is None:
        mock_session = MagicMock()
    yield mock_session


def _make_resource(
    url="https://example.com/page",
    research_id=1,
    title="Title",
    content_preview="Preview",
    source_type="web",
    created_at="2025-01-15T10:00:00",
):
    r = MagicMock()
    r.url = url
    r.research_id = research_id
    r.title = title
    r.content_preview = content_preview
    # get_link_analytics reads the SQL-projected ``has_preview`` boolean
    # (content_preview IS NOT NULL AND != '') rather than the body (#4560).
    r.has_preview = bool(content_preview)
    r.source_type = source_type
    r.created_at = created_at
    return r


def _make_rating(
    rating_value=4, created_at=None, updated_at=None, research_id="res-1"
):
    r = MagicMock()
    r.rating = rating_value
    r.research_id = research_id
    r.created_at = created_at or datetime.now(UTC)
    r.updated_at = updated_at or datetime.now(UTC)
    return r


def _make_classification(
    domain="example.com",
    category="Technology",
    subcategory="Software",
    confidence=0.95,
):
    c = MagicMock()
    c.domain = domain
    c.category = category
    c.subcategory = subcategory
    c.confidence = confidence
    return c


def _make_rate_limit_estimate(
    engine_type="google",
    base_wait_seconds=1.0,
    min_wait_seconds=0.5,
    max_wait_seconds=2.0,
    success_rate=0.9,
    total_attempts=100,
    last_updated=None,
):
    e = MagicMock()
    e.engine_type = engine_type
    e.base_wait_seconds = base_wait_seconds
    e.min_wait_seconds = min_wait_seconds
    e.max_wait_seconds = max_wait_seconds
    e.success_rate = success_rate
    e.total_attempts = total_attempts
    e.last_updated = last_updated or time.time()
    return e


def _make_token_usage(
    model_name="gpt-4",
    prompt_tokens=100,
    completion_tokens=50,
    research_id="res-1",
    timestamp=None,
    search_engine_selected="google",
):
    t = MagicMock()
    t.model_name = model_name
    t.prompt_tokens = prompt_tokens
    t.completion_tokens = completion_tokens
    t.research_id = research_id
    t.timestamp = timestamp or datetime.now(UTC).isoformat()
    t.search_engine_selected = search_engine_selected
    t.provider = "openai"
    return t


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def app():
    with patch(
        "local_deep_research.web.auth.decorators.db_manager"
    ) as mock_dbm:
        mock_dbm.is_user_connected.return_value = True

        flask_app = Flask(__name__)
        flask_app.config["SECRET_KEY"] = "test-secret"
        flask_app.config["TESTING"] = True
        flask_app.config["WTF_CSRF_ENABLED"] = False

        # Add a dummy auth.login endpoint so login_required redirects work
        auth_bp = Blueprint("auth", __name__)

        @auth_bp.route("/login")
        def login():
            return "login page"

        flask_app.register_blueprint(auth_bp)
        flask_app.register_blueprint(metrics_bp)
        yield flask_app


@pytest.fixture
def client(app):
    return app.test_client()


@pytest.fixture
def authed_client(client):
    _auth_session(client)
    return client


# =========================================================================
# _extract_domain
# =========================================================================


class TestExtractDomain:
    def test_simple_url(self):
        assert _extract_domain("https://example.com/page") == "example.com"

    def test_strips_www(self):
        assert _extract_domain("https://www.example.com") == "example.com"

    def test_preserves_subdomain(self):
        assert (
            _extract_domain("https://api.example.com/v1") == "api.example.com"
        )

    def test_empty_url_returns_none(self):
        assert _extract_domain("") is None

    def test_invalid_url_no_netloc(self):
        assert _extract_domain("not-a-url") is None

    def test_none_input_returns_none(self):
        """None input should return None instead of leaking TypeError."""
        assert _extract_domain(None) is None

    def test_url_with_port(self):
        assert (
            _extract_domain("https://example.com:8080/path")
            == "example.com:8080"
        )

    def test_case_normalization(self):
        assert _extract_domain("https://EXAMPLE.COM") == "example.com"


# =========================================================================
# get_available_strategies
# =========================================================================


class TestGetAvailableStrategies:
    def test_returns_list(self):
        result = get_available_strategies()
        assert isinstance(result, list)
        # 5 strategies after the 'mcp'/'agentic' strategy was removed (#4548):
        # source-based, focused-iteration, focused-iteration-standard,
        # topic-organization, langgraph-agent.
        assert len(result) == 5
        assert "mcp" not in {s["name"] for s in result}

    def test_each_strategy_has_name_and_description(self):
        for s in get_available_strategies():
            assert "name" in s
            assert "description" in s

    def test_source_based_strategy_present(self):
        names = [s["name"] for s in get_available_strategies()]
        assert "source-based" in names

    def test_topic_organization_strategy_present(self):
        names = [s["name"] for s in get_available_strategies()]
        assert "topic-organization" in names


# =========================================================================
# get_rating_analytics
# =========================================================================


class TestGetRatingAnalytics:
    @patch("local_deep_research.web.routes.metrics_routes.flask_session", {})
    def test_no_username_returns_error(self):
        result = get_rating_analytics(username=None)
        assert result["rating_analytics"]["error"] == "No user session"
        assert result["rating_analytics"]["total_ratings"] == 0

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    @patch(
        "local_deep_research.web.routes.metrics_routes.flask_session",
        {"username": "testuser"},
    )
    def test_no_ratings_returns_empty(self, mock_db):
        mock_session = MagicMock()
        mock_session.query.return_value.filter.return_value.all.return_value = []
        mock_session.query.return_value.all.return_value = []
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_rating_analytics(period="all", username="testuser")
        assert result["rating_analytics"]["total_ratings"] == 0
        assert result["rating_analytics"]["avg_rating"] is None

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_with_ratings(self, mock_db):
        ratings = [_make_rating(5), _make_rating(4), _make_rating(3)]
        mock_session = MagicMock()
        mock_session.query.return_value.filter.return_value.all.return_value = (
            ratings
        )
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_rating_analytics(period="30d", username="testuser")
        analytics = result["rating_analytics"]
        assert analytics["total_ratings"] == 3
        assert analytics["avg_rating"] == 4.0
        assert analytics["satisfaction_stats"]["very_satisfied"] == 1

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_period_all_no_filter(self, mock_db):
        """When period='all', days is None so no time filter applied."""
        ratings = [_make_rating(5)]
        mock_session = MagicMock()
        # 'all' period -> no filter -> query.all() directly
        mock_session.query.return_value.all.return_value = ratings
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_rating_analytics(period="all", username="testuser")
        assert result["rating_analytics"]["total_ratings"] == 1

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_exception_returns_error_structure(self, mock_db):
        mock_db.return_value.__enter__ = MagicMock(
            side_effect=Exception("DB error")
        )
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_rating_analytics(username="testuser")
        assert result["rating_analytics"]["total_ratings"] == 0

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_unknown_period_defaults_to_30(self, mock_db):
        ratings = [_make_rating(3)]
        mock_session = MagicMock()
        mock_session.query.return_value.filter.return_value.all.return_value = (
            ratings
        )
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_rating_analytics(period="unknown", username="testuser")
        assert result["rating_analytics"]["total_ratings"] == 1


# =========================================================================
# get_strategy_analytics
# =========================================================================


class TestGetStrategyAnalytics:
    @patch("local_deep_research.web.routes.metrics_routes.flask_session", {})
    def test_no_username(self):
        result = get_strategy_analytics(username=None)
        assert result["strategy_analytics"]["error"] == "No user session"

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_no_strategies_in_db(self, mock_db):
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 0
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_strategy_analytics(username="testuser")
        analytics = result["strategy_analytics"]
        assert analytics["total_research"] == 0
        assert "message" in analytics

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_with_strategies(self, mock_db):
        mock_session = MagicMock()
        # First count call returns > 0
        mock_session.query.return_value.count.side_effect = [5, 5]
        # Strategy results
        mock_session.query.return_value.filter.return_value.group_by.return_value.order_by.return_value.all.return_value = [
            ("standard", 3),
            ("smart", 2),
        ]
        # Total count
        mock_session.query.return_value.filter.return_value.count.return_value = 5
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_strategy_analytics(period="30d", username="testuser")
        analytics = result["strategy_analytics"]
        assert analytics["most_popular_strategy"] == "standard"

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_exception(self, mock_db):
        mock_db.return_value.__enter__ = MagicMock(
            side_effect=Exception("fail")
        )
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_strategy_analytics(username="testuser")
        assert "error" in result["strategy_analytics"]


# =========================================================================
# get_rate_limiting_analytics
# =========================================================================


class TestGetRateLimitingAnalytics:
    @patch("local_deep_research.web.routes.metrics_routes.flask_session", {})
    def test_no_username(self):
        result = get_rate_limiting_analytics(username=None)
        assert result["rate_limiting"]["error"] == "No user session"

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_estimates_populate_engine_stats(self, mock_db):
        """Analytics are derived from RateLimitEstimate, NOT the
        never-written RateLimitAttempt table (#4457 follow-up). Two persisted
        estimates should surface both engines, classify health from the
        stored success_rate, and aggregate the recent-window attempt counts.
        """
        google_updated = 1_700_000_000.0  # fixed epoch for a deterministic ISO
        estimates = [
            _make_rate_limit_estimate(
                "google",
                success_rate=0.9,
                total_attempts=100,
                last_updated=google_updated,
            ),
            _make_rate_limit_estimate(
                "bing", success_rate=0.4, total_attempts=50
            ),
        ]

        mock_session = MagicMock()
        q = MagicMock()
        mock_session.query.return_value = q
        q.filter.return_value = q  # recency filter returns the same query
        q.all.return_value = estimates

        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_rate_limiting_analytics(period="30d", username="testuser")
        rl = result["rate_limiting"]

        assert rl["tracked_engines"] == 2
        assert len(rl["engine_stats"]) == 2
        # success_rate 0.9 -> healthy; 0.4 -> poor
        assert rl["healthy_engines"] == 1
        assert rl["poor_engines"] == 1
        assert rl["degraded_engines"] == 0
        # total_attempts is the sum of each estimate's recent window
        assert rl["total_attempts"] == 150
        # successful = round(100*0.9) + round(50*0.4) = 90 + 20
        assert rl["successful_attempts"] == 110
        assert rl["failed_attempts"] == 40
        # Not derivable from estimates — must stay 0, not crash.
        assert rl["rate_limit_events"] == 0

        google = next(s for s in rl["engine_stats"] if s["engine"] == "google")
        assert google["success_rate"] == 90.0
        assert google["status"] == "healthy"
        assert google["base_wait_seconds"] == 1.0
        # last_updated is the estimate's epoch rendered as an ISO-8601 UTC
        # string; assert it round-trips to the exact stored timestamp.
        assert (
            google["last_updated"]
            == datetime.fromtimestamp(google_updated, UTC).isoformat()
        )

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_no_estimates_returns_zeroed_panel(self, mock_db):
        """No persisted estimates -> a clean all-zero panel (no crash)."""
        mock_session = MagicMock()
        q = MagicMock()
        mock_session.query.return_value = q
        q.filter.return_value = q
        q.all.return_value = []

        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_rate_limiting_analytics(period="30d", username="testuser")
        rl = result["rate_limiting"]
        assert rl["tracked_engines"] == 0
        assert rl["engine_stats"] == []
        assert rl["total_attempts"] == 0
        assert rl["avg_wait_time"] == 0
        assert "error" not in rl

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_period_all(self, mock_db):
        """Tests the 'all' period branch where cutoff_time=0."""
        mock_session = MagicMock()
        rate_query = MagicMock()
        mock_session.query.return_value = rate_query
        rate_query.filter.return_value = rate_query
        rate_query.count.return_value = 0
        rate_query.all.return_value = []
        rate_query.scalar.return_value = 0
        rate_query.distinct.return_value = rate_query

        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_rate_limiting_analytics(period="all", username="testuser")
        assert result["rate_limiting"]["total_attempts"] == 0

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_period_7d(self, mock_db):
        mock_session = MagicMock()
        rate_query = MagicMock()
        mock_session.query.return_value = rate_query
        rate_query.filter.return_value = rate_query
        rate_query.count.return_value = 0
        rate_query.all.return_value = []
        rate_query.scalar.return_value = 0
        rate_query.distinct.return_value = rate_query

        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_rate_limiting_analytics(period="7d", username="testuser")
        assert "rate_limiting" in result

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_period_3m(self, mock_db):
        mock_session = MagicMock()
        rate_query = MagicMock()
        mock_session.query.return_value = rate_query
        rate_query.filter.return_value = rate_query
        rate_query.count.return_value = 0
        rate_query.all.return_value = []
        rate_query.scalar.return_value = 0
        rate_query.distinct.return_value = rate_query

        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_rate_limiting_analytics(period="3m", username="testuser")
        assert "rate_limiting" in result

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_period_1y(self, mock_db):
        mock_session = MagicMock()
        rate_query = MagicMock()
        mock_session.query.return_value = rate_query
        rate_query.filter.return_value = rate_query
        rate_query.count.return_value = 0
        rate_query.all.return_value = []
        rate_query.scalar.return_value = 0
        rate_query.distinct.return_value = rate_query

        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_rate_limiting_analytics(period="1y", username="testuser")
        assert "rate_limiting" in result

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_exception(self, mock_db):
        mock_db.return_value.__enter__ = MagicMock(
            side_effect=Exception("fail")
        )
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_rate_limiting_analytics(username="testuser")
        assert "error" in result["rate_limiting"]


# =========================================================================
# get_link_analytics
# =========================================================================


class TestGetLinkAnalytics:
    @patch("local_deep_research.web.routes.metrics_routes.flask_session", {})
    def test_no_username(self):
        result = get_link_analytics(username=None)
        assert result["link_analytics"]["error"] == "No user session"

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_no_resources(self, mock_db):
        mock_session = MagicMock()
        mock_session.query.return_value.filter.return_value.all.return_value = []
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_link_analytics(period="30d", username="testuser")
        assert result["link_analytics"]["total_links"] == 0

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_with_resources_and_classifications(self, mock_db):
        resources = [
            _make_resource("https://example.com/1", 1, "Title1", "Preview1"),
            _make_resource("https://example.com/2", 1, "Title2", "Preview2"),
            _make_resource(
                "https://other.com/1", 2, "Title3", None, "academic"
            ),
        ]
        classification = _make_classification("example.com", "Technology")

        mock_session = MagicMock()
        # First call: resources query
        query_mock = MagicMock()
        query_mock.filter.return_value.all.return_value = resources
        # Domain classifications query
        classifications_query = MagicMock()
        classifications_query.filter.return_value.all.return_value = [
            classification
        ]
        # Research history query
        research_mock = MagicMock()
        research_mock.id = 1
        research_mock.query = "Test research query"
        research_history_query = MagicMock()
        research_history_query.filter.return_value.all.return_value = [
            research_mock
        ]

        mock_session.query.side_effect = [
            query_mock,  # ResearchResource query
            classifications_query,  # DomainClassification batch query
            research_history_query,  # ResearchHistory batch query
        ]
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_link_analytics(period="30d", username="testuser")
        analytics = result["link_analytics"]
        assert analytics["total_links"] == 3
        assert analytics["total_unique_domains"] == 2

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_period_all(self, mock_db):
        mock_session = MagicMock()
        # No time filter applied for 'all'
        mock_session.query.return_value.all.return_value = []
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_link_analytics(period="all", username="testuser")
        assert result["link_analytics"]["total_links"] == 0

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_exception(self, mock_db):
        mock_db.return_value.__enter__ = MagicMock(
            side_effect=Exception("fail")
        )
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_link_analytics(username="testuser")
        assert "error" in result["link_analytics"]

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_resource_with_no_url(self, mock_db):
        """Resource with url=None should be skipped."""
        resources = [_make_resource(url=None)]

        mock_session = MagicMock()
        query_mock = MagicMock()
        query_mock.filter.return_value.all.return_value = resources
        classifications_query = MagicMock()
        classifications_query.filter.return_value.all.return_value = []
        research_history_query = MagicMock()
        research_history_query.filter.return_value.all.return_value = []

        mock_session.query.side_effect = [
            query_mock,
            classifications_query,
            research_history_query,
        ]
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        result = get_link_analytics(period="30d", username="testuser")
        # Resources with no URL contribute to total but not to domain counts
        assert result["link_analytics"]["total_links"] == 1
        assert result["link_analytics"]["total_unique_domains"] == 0


# =========================================================================
# Route tests: metrics_dashboard, context_overflow_page, etc.
# =========================================================================


class TestDashboardPages:
    @patch(
        "local_deep_research.web.routes.metrics_routes.render_template_with_defaults"
    )
    def test_metrics_dashboard(self, mock_render, authed_client):
        mock_render.return_value = "dashboard"
        resp = authed_client.get("/metrics/")
        assert resp.status_code == 200

    @patch(
        "local_deep_research.web.routes.metrics_routes.render_template_with_defaults"
    )
    def test_context_overflow_page(self, mock_render, authed_client):
        mock_render.return_value = "overflow"
        resp = authed_client.get("/metrics/context-overflow")
        assert resp.status_code == 200

    @patch(
        "local_deep_research.web.routes.metrics_routes.render_template_with_defaults"
    )
    def test_star_reviews_page(self, mock_render, authed_client):
        mock_render.return_value = "reviews"
        resp = authed_client.get("/metrics/star-reviews")
        assert resp.status_code == 200

    @patch(
        "local_deep_research.web.routes.metrics_routes.render_template_with_defaults"
    )
    def test_cost_analytics_page(self, mock_render, authed_client):
        mock_render.return_value = "costs"
        resp = authed_client.get("/metrics/costs")
        assert resp.status_code == 200

    @patch(
        "local_deep_research.web.routes.metrics_routes.render_template_with_defaults"
    )
    def test_link_analytics_page(self, mock_render, authed_client):
        mock_render.return_value = "links"
        resp = authed_client.get("/metrics/links")
        assert resp.status_code == 200


# =========================================================================
# Route: /api/metrics
# =========================================================================


class TestApiMetrics:
    @patch(
        "local_deep_research.web.routes.metrics_routes.get_rate_limiting_analytics"
    )
    @patch(
        "local_deep_research.web.routes.metrics_routes.get_strategy_analytics"
    )
    @patch(
        "local_deep_research.web.routes.metrics_routes.get_time_filter_condition"
    )
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    @patch("local_deep_research.web.routes.metrics_routes.get_search_tracker")
    @patch("local_deep_research.web.routes.metrics_routes.TokenCounter")
    def test_success(
        self,
        mock_tc,
        mock_st,
        mock_db,
        mock_time_filter,
        mock_strategy,
        mock_rate_limit,
        authed_client,
    ):
        mock_tc.return_value.get_overall_metrics.return_value = {"tokens": 100}
        mock_st.return_value.get_search_metrics.return_value = {"searches": 5}
        mock_strategy.return_value = {"strategy_analytics": {}}
        mock_rate_limit.return_value = {"rate_limiting": {}}
        mock_time_filter.return_value = None

        mock_session = MagicMock()
        mock_session.query.return_value.with_entities.return_value.scalar.return_value = 4.5
        mock_session.query.return_value.count.return_value = 10
        # Handle the filter path too
        mock_session.query.return_value.filter.return_value.with_entities.return_value.scalar.return_value = 4.5
        mock_session.query.return_value.filter.return_value.count.return_value = 10
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/metrics?period=30d&mode=all")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"

    @patch("local_deep_research.web.routes.metrics_routes.TokenCounter")
    def test_exception(self, mock_tc, authed_client):
        mock_tc.side_effect = Exception("fail")

        resp = authed_client.get("/metrics/api/metrics")
        assert resp.status_code == 500
        data = resp.get_json()
        assert data["status"] == "error"

    @patch(
        "local_deep_research.web.routes.metrics_routes.get_rate_limiting_analytics"
    )
    @patch(
        "local_deep_research.web.routes.metrics_routes.get_strategy_analytics"
    )
    @patch(
        "local_deep_research.web.routes.metrics_routes.get_time_filter_condition"
    )
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    @patch("local_deep_research.web.routes.metrics_routes.get_search_tracker")
    @patch("local_deep_research.web.routes.metrics_routes.TokenCounter")
    def test_user_satisfaction_exception(
        self,
        mock_tc,
        mock_st,
        mock_db,
        mock_time_filter,
        mock_strategy,
        mock_rate_limit,
        authed_client,
    ):
        """When getting user satisfaction raises an exception, it should fallback."""
        mock_tc.return_value.get_overall_metrics.return_value = {}
        mock_st.return_value.get_search_metrics.return_value = {}
        mock_strategy.return_value = {"strategy_analytics": {}}
        mock_rate_limit.return_value = {"rate_limiting": {}}
        mock_time_filter.return_value = None

        mock_db.return_value.__enter__ = MagicMock(
            side_effect=Exception("DB error")
        )
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/metrics")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["metrics"]["user_satisfaction"]["avg_rating"] is None


# =========================================================================
# Route: /api/rate-limiting
# =========================================================================


class TestApiRateLimiting:
    @patch(
        "local_deep_research.web.routes.metrics_routes.get_rate_limiting_analytics"
    )
    def test_success(self, mock_analytics, authed_client):
        mock_analytics.return_value = {"rate_limiting": {"total_attempts": 5}}

        resp = authed_client.get("/metrics/api/rate-limiting?period=7d")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert data["period"] == "7d"

    @patch(
        "local_deep_research.web.routes.metrics_routes.get_rate_limiting_analytics"
    )
    def test_exception(self, mock_analytics, authed_client):
        mock_analytics.side_effect = Exception("fail")

        resp = authed_client.get("/metrics/api/rate-limiting")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/rate-limiting/current
# =========================================================================


class TestApiCurrentRateLimits:
    @staticmethod
    def _make_estimate(engine_type, success_rate, base_wait=1.0):
        est = MagicMock()
        est.engine_type = engine_type
        est.base_wait_seconds = base_wait
        est.min_wait_seconds = base_wait * 0.5
        est.max_wait_seconds = base_wait * 2.0
        est.success_rate = success_rate
        est.total_attempts = 50
        est.last_updated = time.time()
        return est

    @staticmethod
    def _wire_session(mock_db, estimates):
        session = MagicMock()
        session.query.return_value.order_by.return_value.all.return_value = (
            estimates
        )
        mock_db.return_value.__enter__ = MagicMock(return_value=session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_success_healthy(self, mock_db, authed_client):
        self._wire_session(mock_db, [self._make_estimate("google", 0.9)])

        resp = authed_client.get("/metrics/api/rate-limiting/current")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert len(data["current_limits"]) == 1
        assert data["current_limits"][0]["status"] == "healthy"

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_degraded_status(self, mock_db, authed_client):
        self._wire_session(mock_db, [self._make_estimate("bing", 0.6)])

        resp = authed_client.get("/metrics/api/rate-limiting/current")
        data = resp.get_json()
        assert data["current_limits"][0]["status"] == "degraded"

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_poor_status(self, mock_db, authed_client):
        self._wire_session(mock_db, [self._make_estimate("duckduckgo", 0.3)])

        resp = authed_client.get("/metrics/api/rate-limiting/current")
        data = resp.get_json()
        assert data["current_limits"][0]["status"] == "poor"

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_exception(self, mock_db, authed_client):
        mock_db.return_value.__enter__ = MagicMock(
            side_effect=Exception("fail")
        )
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/rate-limiting/current")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/metrics/research/<id>/links
# =========================================================================


class TestApiResearchLinkMetrics:
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_no_resources(self, mock_db, authed_client):
        mock_session = MagicMock()
        mock_session.query.return_value.filter.return_value.all.return_value = []
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/metrics/research/res-1/links")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["data"]["total_links"] == 0

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_with_resources(self, mock_db, authed_client):
        resources = [
            _make_resource("https://example.com/1", "res-1"),
            _make_resource(
                "https://other.com/1",
                "res-1",
                title=None,
                content_preview="short",
            ),
        ]
        classification = _make_classification("example.com")

        mock_session = MagicMock()
        # First query: ResearchResource
        resource_query = MagicMock()
        resource_query.filter.return_value.all.return_value = resources
        # Second query: DomainClassification
        class_query = MagicMock()
        class_query.filter.return_value.all.return_value = [classification]

        mock_session.query.side_effect = [resource_query, class_query]
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/metrics/research/res-1/links")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["data"]["total_links"] == 2
        assert data["data"]["unique_domains"] == 2

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_exception(self, mock_db, authed_client):
        mock_db.return_value.__enter__ = MagicMock(
            side_effect=Exception("fail")
        )
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/metrics/research/res-1/links")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/metrics/research/<id>
# =========================================================================


class TestApiResearchMetrics:
    @patch("local_deep_research.web.routes.metrics_routes.TokenCounter")
    def test_success(self, mock_tc, authed_client):
        mock_tc.return_value.get_research_metrics.return_value = {"total": 500}

        resp = authed_client.get("/metrics/api/metrics/research/res-1")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"

    @patch("local_deep_research.web.routes.metrics_routes.TokenCounter")
    def test_exception(self, mock_tc, authed_client):
        mock_tc.return_value.get_research_metrics.side_effect = Exception(
            "fail"
        )

        resp = authed_client.get("/metrics/api/metrics/research/res-1")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/metrics/research/<id>/timeline
# =========================================================================


class TestApiResearchTimelineMetrics:
    @patch("local_deep_research.web.routes.metrics_routes.TokenCounter")
    def test_success(self, mock_tc, authed_client):
        mock_tc.return_value.get_research_timeline_metrics.return_value = {
            "timeline": []
        }

        resp = authed_client.get("/metrics/api/metrics/research/res-1/timeline")
        assert resp.status_code == 200

    @patch("local_deep_research.web.routes.metrics_routes.TokenCounter")
    def test_exception(self, mock_tc, authed_client):
        mock_tc.return_value.get_research_timeline_metrics.side_effect = (
            Exception("fail")
        )

        resp = authed_client.get("/metrics/api/metrics/research/res-1/timeline")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/metrics/research/<id>/search
# =========================================================================


class TestApiResearchSearchMetrics:
    @patch("local_deep_research.web.routes.metrics_routes.get_search_tracker")
    def test_success(self, mock_st, authed_client):
        mock_st.return_value.get_research_search_metrics.return_value = {
            "queries": 3
        }

        resp = authed_client.get("/metrics/api/metrics/research/res-1/search")
        assert resp.status_code == 200

    @patch("local_deep_research.web.routes.metrics_routes.get_search_tracker")
    def test_exception(self, mock_st, authed_client):
        mock_st.return_value.get_research_search_metrics.side_effect = (
            Exception("fail")
        )

        resp = authed_client.get("/metrics/api/metrics/research/res-1/search")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/metrics/enhanced
# =========================================================================


class TestApiEnhancedMetrics:
    @patch("local_deep_research.web.routes.metrics_routes.get_rating_analytics")
    @patch("local_deep_research.web.routes.metrics_routes.get_search_tracker")
    @patch("local_deep_research.web.routes.metrics_routes.TokenCounter")
    def test_success(self, mock_tc, mock_st, mock_rating, authed_client):
        mock_tc.return_value.get_enhanced_metrics.return_value = {
            "enhanced": True
        }
        mock_st.return_value.get_search_time_series.return_value = []
        mock_rating.return_value = {"rating_analytics": {"avg_rating": 4.0}}

        resp = authed_client.get(
            "/metrics/api/metrics/enhanced?period=7d&mode=web"
        )
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert data["period"] == "7d"
        assert data["research_mode"] == "web"

    @patch("local_deep_research.web.routes.metrics_routes.TokenCounter")
    def test_exception(self, mock_tc, authed_client):
        mock_tc.side_effect = Exception("fail")

        resp = authed_client.get("/metrics/api/metrics/enhanced")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/ratings/<id> GET
# =========================================================================


class TestApiGetResearchRating:
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_rating_exists(self, mock_db, authed_client):
        rating = _make_rating(5)
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = rating
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/ratings/res-1")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["rating"] == 5

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_no_rating(self, mock_db, authed_client):
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = None
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/ratings/res-1")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["rating"] is None

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_exception(self, mock_db, authed_client):
        mock_db.return_value.__enter__ = MagicMock(
            side_effect=Exception("fail")
        )
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/ratings/res-1")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/ratings/<id> POST
# =========================================================================


class TestApiSaveResearchRating:
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_create_new_rating(self, mock_db, authed_client):
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = None
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.post(
            "/metrics/api/ratings/res-1",
            json={"rating": 4},
            content_type="application/json",
        )
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert data["rating"] == 4

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_update_existing_rating(self, mock_db, authed_client):
        existing = _make_rating(3)
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = existing
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.post(
            "/metrics/api/ratings/res-1",
            json={"rating": 5},
            content_type="application/json",
        )
        assert resp.status_code == 200
        assert existing.rating == 5

    def test_invalid_rating_too_high(self, authed_client):
        resp = authed_client.post(
            "/metrics/api/ratings/res-1",
            json={"rating": 6},
            content_type="application/json",
        )
        assert resp.status_code == 400

    def test_invalid_rating_too_low(self, authed_client):
        resp = authed_client.post(
            "/metrics/api/ratings/res-1",
            json={"rating": 0},
            content_type="application/json",
        )
        assert resp.status_code == 400

    def test_invalid_rating_not_int(self, authed_client):
        resp = authed_client.post(
            "/metrics/api/ratings/res-1",
            json={"rating": "five"},
            content_type="application/json",
        )
        assert resp.status_code == 400

    def test_missing_rating(self, authed_client):
        resp = authed_client.post(
            "/metrics/api/ratings/res-1",
            json={},
            content_type="application/json",
        )
        assert resp.status_code == 400

    def test_no_json_body(self, authed_client):
        resp = authed_client.post("/metrics/api/ratings/res-1")
        # require_json_body decorator returns error
        assert resp.status_code in (400, 415)

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_exception(self, mock_db, authed_client):
        mock_db.return_value.__enter__ = MagicMock(
            side_effect=Exception("fail")
        )
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.post(
            "/metrics/api/ratings/res-1",
            json={"rating": 3},
            content_type="application/json",
        )
        assert resp.status_code == 500


# =========================================================================
# Route: /api/star-reviews
# =========================================================================


class TestApiStarReviews:
    @patch(
        "local_deep_research.web.routes.metrics_routes.get_time_filter_condition"
    )
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_success(self, mock_db, mock_time_filter, authed_client):
        mock_time_filter.return_value = None

        mock_session = MagicMock()
        # overall_stats (also used as dimension_stats since query.first() is shared)
        overall_result = MagicMock()
        overall_result.avg_rating = 4.2
        overall_result.total_ratings = 10
        overall_result.five_star = 3
        overall_result.four_star = 4
        overall_result.three_star = 2
        overall_result.two_star = 1
        overall_result.one_star = 0
        # quality dimension fields (returned by the same query.first() mock)
        overall_result.avg_accuracy = 4.0
        overall_result.avg_completeness = 3.8
        overall_result.avg_relevance = 4.1
        overall_result.avg_readability = 3.9
        overall_result.dimension_count = 5
        overall_result.count_accuracy = 5
        overall_result.count_completeness = 4
        overall_result.count_relevance = 4
        overall_result.count_readability = 3

        # Set up query chain
        query = MagicMock()
        mock_session.query.return_value = query
        query.filter.return_value = query
        query.select_from.return_value = query
        query.distinct.return_value = query
        query.subquery.return_value = query
        query.outerjoin.return_value = query
        query.group_by.return_value = query
        query.having.return_value = query
        query.order_by.return_value = query
        query.limit.return_value = query
        query.first.return_value = overall_result
        query.all.return_value = []

        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/star-reviews?period=30d")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "overall_stats" in data

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_exception(self, mock_db, authed_client):
        mock_db.return_value.__enter__ = MagicMock(
            side_effect=Exception("fail")
        )
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/star-reviews")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/pricing
# =========================================================================


class TestApiPricing:
    @patch("local_deep_research.metrics.pricing.pricing_fetcher.PricingFetcher")
    def test_success(self, mock_pf, authed_client):
        mock_pf.return_value.static_pricing = {"gpt-4": {"input": 0.03}}
        resp = authed_client.get("/metrics/api/pricing")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"

    @patch("local_deep_research.metrics.pricing.pricing_fetcher.PricingFetcher")
    def test_exception(self, mock_pf, authed_client):
        mock_pf.side_effect = Exception("fail")
        resp = authed_client.get("/metrics/api/pricing")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/pricing/<model_name>
# =========================================================================


class TestApiModelPricing:
    def test_exception(self, authed_client):
        with patch(
            "local_deep_research.metrics.pricing.cost_calculator.CostCalculator",
            side_effect=Exception("fail"),
        ):
            resp = authed_client.get("/metrics/api/pricing/gpt-4")
            assert resp.status_code == 500


# =========================================================================
# Route: /api/cost-calculation POST
# =========================================================================


class TestApiCostCalculation:
    def test_missing_model_name(self, authed_client):
        resp = authed_client.post(
            "/metrics/api/cost-calculation",
            json={"prompt_tokens": 100},
            content_type="application/json",
        )
        assert resp.status_code == 400

    def test_no_json_body(self, authed_client):
        resp = authed_client.post("/metrics/api/cost-calculation")
        assert resp.status_code in (400, 415)

    def test_exception(self, authed_client):
        with patch(
            "local_deep_research.metrics.pricing.cost_calculator.CostCalculator",
            side_effect=Exception("fail"),
        ):
            resp = authed_client.post(
                "/metrics/api/cost-calculation",
                json={
                    "model_name": "gpt-4",
                    "prompt_tokens": 100,
                    "completion_tokens": 50,
                },
                content_type="application/json",
            )
            assert resp.status_code == 500


# =========================================================================
# Route: /api/research-costs/<id>
# =========================================================================


class TestApiResearchCosts:
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_no_records(self, mock_db, authed_client):
        mock_session = MagicMock()
        mock_session.query.return_value.filter.return_value.all.return_value = []
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/research-costs/res-1")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["total_cost"] == 0.0

    @patch("local_deep_research.metrics.pricing.cost_calculator.CostCalculator")
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_with_records(self, mock_db, mock_calc, authed_client):
        records = [_make_token_usage()]
        mock_session = MagicMock()
        mock_session.query.return_value.filter.return_value.all.return_value = (
            records
        )
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        mock_calc.return_value.calculate_cost_sync.return_value = {
            "total_cost": 0.005
        }

        resp = authed_client.get("/metrics/api/research-costs/res-1")
        assert resp.status_code == 200

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_exception(self, mock_db, authed_client):
        mock_db.return_value.__enter__ = MagicMock(
            side_effect=Exception("fail")
        )
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/research-costs/res-1")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/cost-analytics
# =========================================================================


class TestApiCostAnalytics:
    @patch(
        "local_deep_research.web.routes.metrics_routes.get_time_filter_condition"
    )
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_no_records(self, mock_db, mock_time_filter, authed_client):
        mock_time_filter.return_value = None
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 0
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/cost-analytics?period=30d")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["overview"]["total_cost"] == 0.0

    @patch("local_deep_research.metrics.pricing.cost_calculator.CostCalculator")
    @patch(
        "local_deep_research.web.routes.metrics_routes.get_time_filter_condition"
    )
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_with_records(
        self, mock_db, mock_time_filter, mock_calc, authed_client
    ):
        mock_time_filter.return_value = None
        records = [_make_token_usage()]
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1
        mock_session.query.return_value.all.return_value = records
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        mock_calc.return_value.calculate_cost_sync.return_value = {
            "total_cost": 0.01
        }

        resp = authed_client.get("/metrics/api/cost-analytics")
        assert resp.status_code == 200

    @patch("local_deep_research.metrics.pricing.cost_calculator.CostCalculator")
    @patch(
        "local_deep_research.web.routes.metrics_routes.get_time_filter_condition"
    )
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_large_dataset_limited(
        self, mock_db, mock_time_filter, mock_calc, authed_client
    ):
        """When record_count > 1000, it limits to 1000."""
        mock_time_filter.return_value = None
        records = [_make_token_usage()]
        mock_session = MagicMock()
        mock_session.query.return_value.count.return_value = 1500
        mock_session.query.return_value.order_by.return_value.limit.return_value.all.return_value = records
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        mock_calc.return_value.calculate_cost_sync.return_value = {
            "total_cost": 0.01
        }

        resp = authed_client.get("/metrics/api/cost-analytics")
        assert resp.status_code == 200

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_exception(self, mock_db, authed_client):
        mock_db.return_value.__enter__ = MagicMock(
            side_effect=Exception("fail")
        )
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/cost-analytics")
        # Returns 200 with error message to avoid breaking UI
        assert resp.status_code == 200
        data = resp.get_json()
        assert "error" in data


# =========================================================================
# Route: /api/link-analytics
# =========================================================================


class TestApiLinkAnalytics:
    @patch("local_deep_research.web.routes.metrics_routes.get_link_analytics")
    def test_success(self, mock_analytics, authed_client):
        mock_analytics.return_value = {
            "link_analytics": {"total_links": 5, "top_domains": []}
        }

        resp = authed_client.get("/metrics/api/link-analytics?period=7d")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert data["period"] == "7d"

    @patch("local_deep_research.web.routes.metrics_routes.get_link_analytics")
    def test_exception(self, mock_analytics, authed_client):
        mock_analytics.side_effect = Exception("fail")

        resp = authed_client.get("/metrics/api/link-analytics")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/domain-classifications GET
# =========================================================================


class TestApiGetDomainClassifications:
    @patch("local_deep_research.web.routes.metrics_routes.DomainClassifier")
    def test_success(self, mock_dc, authed_client):
        mock_classifier = MagicMock()
        classification = MagicMock()
        classification.to_dict.return_value = {"domain": "example.com"}
        mock_classifier.get_all_classifications.return_value = [classification]
        mock_dc.return_value = mock_classifier

        resp = authed_client.get("/metrics/api/domain-classifications")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["total"] == 1
        mock_classifier.close.assert_called_once()

    @patch("local_deep_research.web.routes.metrics_routes.DomainClassifier")
    def test_exception(self, mock_dc, authed_client):
        mock_dc.side_effect = Exception("fail")

        resp = authed_client.get("/metrics/api/domain-classifications")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/domain-classifications/summary GET
# =========================================================================


class TestApiGetClassificationsSummary:
    @patch("local_deep_research.web.routes.metrics_routes.DomainClassifier")
    def test_success(self, mock_dc, authed_client):
        mock_classifier = MagicMock()
        mock_classifier.get_categories_summary.return_value = {"Technology": 5}
        mock_dc.return_value = mock_classifier

        resp = authed_client.get("/metrics/api/domain-classifications/summary")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["summary"]["Technology"] == 5
        mock_classifier.close.assert_called_once()

    @patch("local_deep_research.web.routes.metrics_routes.DomainClassifier")
    def test_exception(self, mock_dc, authed_client):
        mock_dc.side_effect = Exception("fail")

        resp = authed_client.get("/metrics/api/domain-classifications/summary")
        assert resp.status_code == 500


# =========================================================================
# Route: /api/domain-classifications/classify POST
# =========================================================================


class TestApiClassifyDomains:
    @patch("local_deep_research.database.session_context.get_user_db_session")
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    @patch("local_deep_research.web.routes.metrics_routes.DomainClassifier")
    def test_single_domain_success(
        self, mock_dc, mock_db, mock_db_local, authed_client
    ):
        mock_session = MagicMock()
        # Both patches needed since module re-imports get_user_db_session locally
        for db in (mock_db, mock_db_local):
            db.return_value.__enter__ = MagicMock(return_value=mock_session)
            db.return_value.__exit__ = MagicMock(return_value=False)

        classification = MagicMock()
        classification.to_dict.return_value = {"domain": "example.com"}
        mock_classifier = MagicMock()
        mock_classifier.classify_domain.return_value = classification
        mock_dc.return_value = mock_classifier

        with patch("local_deep_research.settings.manager.SettingsManager"):
            resp = authed_client.post(
                "/metrics/api/domain-classifications/classify",
                json={"domain": "example.com"},
                content_type="application/json",
            )
        assert resp.status_code == 200
        mock_classifier.close.assert_called_once()

    @patch("local_deep_research.database.session_context.get_user_db_session")
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    @patch("local_deep_research.web.routes.metrics_routes.DomainClassifier")
    def test_single_domain_failure(
        self, mock_dc, mock_db, mock_db_local, authed_client
    ):
        mock_session = MagicMock()
        for db in (mock_db, mock_db_local):
            db.return_value.__enter__ = MagicMock(return_value=mock_session)
            db.return_value.__exit__ = MagicMock(return_value=False)

        mock_classifier = MagicMock()
        mock_classifier.classify_domain.return_value = None
        mock_dc.return_value = mock_classifier

        with patch("local_deep_research.settings.manager.SettingsManager"):
            resp = authed_client.post(
                "/metrics/api/domain-classifications/classify",
                json={"domain": "bad.com"},
                content_type="application/json",
            )
        assert resp.status_code == 400

    @patch("local_deep_research.database.session_context.get_user_db_session")
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    @patch("local_deep_research.web.routes.metrics_routes.DomainClassifier")
    def test_batch_mode(self, mock_dc, mock_db, mock_db_local, authed_client):
        mock_session = MagicMock()
        for db in (mock_db, mock_db_local):
            db.return_value.__enter__ = MagicMock(return_value=mock_session)
            db.return_value.__exit__ = MagicMock(return_value=False)

        mock_classifier = MagicMock()
        mock_classifier.classify_all_domains.return_value = {"classified": 5}
        mock_dc.return_value = mock_classifier

        with patch("local_deep_research.settings.manager.SettingsManager"):
            resp = authed_client.post(
                "/metrics/api/domain-classifications/classify",
                json={"batch": True},
                content_type="application/json",
            )
        assert resp.status_code == 200

    @patch("local_deep_research.database.session_context.get_user_db_session")
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    @patch("local_deep_research.web.routes.metrics_routes.DomainClassifier")
    def test_no_domain_no_batch(
        self, mock_dc, mock_db, mock_db_local, authed_client
    ):
        mock_session = MagicMock()
        for db in (mock_db, mock_db_local):
            db.return_value.__enter__ = MagicMock(return_value=mock_session)
            db.return_value.__exit__ = MagicMock(return_value=False)
        mock_dc.return_value = MagicMock()

        with patch("local_deep_research.settings.manager.SettingsManager"):
            resp = authed_client.post(
                "/metrics/api/domain-classifications/classify",
                json={},
                content_type="application/json",
            )
        assert resp.status_code == 400

    @patch("local_deep_research.web.routes.metrics_routes.DomainClassifier")
    def test_exception(self, mock_dc, authed_client):
        mock_dc.side_effect = Exception("fail")

        resp = authed_client.post(
            "/metrics/api/domain-classifications/classify",
            json={"domain": "example.com"},
            content_type="application/json",
        )
        assert resp.status_code == 500


# =========================================================================
# Route: /api/domain-classifications/progress GET
# =========================================================================


class TestApiClassificationProgress:
    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_success(self, mock_db, authed_client):
        mock_session = MagicMock()

        # Resources query
        url_row_1 = ("https://example.com/1",)
        url_row_2 = ("https://other.com/1",)
        url_row_3 = (None,)
        resources_query = MagicMock()
        resources_query.distinct.return_value.all.return_value = [
            url_row_1,
            url_row_2,
            url_row_3,
        ]

        # DomainClassification count
        count_query = MagicMock()
        count_query.count.return_value = 1

        mock_session.query.side_effect = [resources_query, count_query]
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/domain-classifications/progress")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["progress"]["total_domains"] == 2
        assert data["progress"]["classified"] == 1
        assert data["progress"]["unclassified"] == 1

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_no_domains(self, mock_db, authed_client):
        mock_session = MagicMock()
        resources_query = MagicMock()
        resources_query.distinct.return_value.all.return_value = []
        count_query = MagicMock()
        count_query.count.return_value = 0

        mock_session.query.side_effect = [resources_query, count_query]
        mock_db.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/domain-classifications/progress")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["progress"]["percentage"] == 0

    @patch("local_deep_research.web.routes.metrics_routes.get_user_db_session")
    def test_exception(self, mock_db, authed_client):
        mock_db.return_value.__enter__ = MagicMock(
            side_effect=Exception("fail")
        )
        mock_db.return_value.__exit__ = MagicMock(return_value=False)

        resp = authed_client.get("/metrics/api/domain-classifications/progress")
        assert resp.status_code == 500


# =========================================================================
# Authentication tests
# =========================================================================


class TestAuthentication:
    def test_unauthenticated_api_returns_401(self, client):
        resp = client.get("/metrics/api/metrics")
        assert resp.status_code in (401, 302)

    def test_unauthenticated_page_redirects(self, client):
        resp = client.get("/metrics/")
        assert resp.status_code in (401, 302)
