"""Coverage tests for metrics/search_tracker.py.

Targets uncovered branches: Exception-based error_type, string error_message
setting unknown_error, outer exception handling in record_search,
engine stats formatting with actual data, success_rate calculation,
long/short query truncation in get_search_metrics and get_search_time_series,
get_research_search_metrics totals and exception path,
get_search_time_series null fields and exception,
get_search_tracker Flask credential flow, no request context, exception fallback.
"""

from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch

import pytest

from local_deep_research.metrics.search_tracker import (
    SearchTracker,
)


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


def _mock_session_cm(mock_session):
    """Return a context-manager mock wrapping *mock_session*."""
    cm = MagicMock()
    cm.__enter__ = Mock(return_value=mock_session)
    cm.__exit__ = Mock(return_value=None)
    return cm


def _setup_db_and_session():
    """Create a mock db + session pair wired together."""
    mock_db = MagicMock()
    mock_session = MagicMock()
    mock_db.get_session.return_value = _mock_session_cm(mock_session)
    return mock_db, mock_session


def _chain_query(mock_session):
    """Make session.query() return a fully-chainable mock query."""
    q = MagicMock()
    mock_session.query.return_value = q
    q.filter.return_value = q
    q.filter_by.return_value = q
    q.group_by.return_value = q
    q.order_by.return_value = q
    q.limit.return_value = q
    q.all.return_value = []
    return q


def _base_context():
    return {
        "research_id": "rid-1",
        "research_query": "some research",
        "research_mode": "quick",
        "research_phase": "search",
        "search_iteration": 1,
        "username": "testuser",
        "user_password": "testpass",
    }


# ===========================================================================
# record_search -- Exception instance extracts type_name
# ===========================================================================


class TestRecordSearchErrorType:
    """Cover the error_type derivation branches in record_search."""

    def _record_with_error(self, error_message, context=None):
        """Call record_search with the given error_message, returning the
        SearchCall object added to the session."""
        ctx = context or _base_context()
        mock_session = MagicMock()
        mock_cm = _mock_session_cm(mock_session)
        mock_writer = MagicMock()
        mock_writer.get_session.return_value = mock_cm

        with (
            patch(
                "local_deep_research.metrics.search_tracker.get_search_context",
                return_value=ctx,
            ),
            patch(
                "local_deep_research.database.thread_metrics.metrics_writer",
                mock_writer,
            ),
        ):
            SearchTracker.record_search(
                engine_name="google",
                query="q",
                success=False,
                error_message=error_message,
            )
        call_obj = mock_session.add.call_args[0][0]
        return call_obj

    def test_exception_instance_extracts_type_name(self):
        """When error_message is an Exception, error_type should be its class name."""
        err = ValueError("bad value")
        call_obj = self._record_with_error(err)
        assert call_obj.error_type == "ValueError"
        assert call_obj.error_message == "bad value"

    def test_string_error_sets_unknown_error(self):
        """When error_message is a plain string, error_type should be 'unknown_error'."""
        call_obj = self._record_with_error("timeout occurred")
        assert call_obj.error_type == "unknown_error"
        assert call_obj.error_message == "timeout occurred"

    def test_no_error_message_leaves_fields_none(self):
        """When error_message is None, error_type and error_message should be None."""
        ctx = _base_context()
        mock_session = MagicMock()
        mock_cm = _mock_session_cm(mock_session)
        mock_writer = MagicMock()
        mock_writer.get_session.return_value = mock_cm

        with (
            patch(
                "local_deep_research.metrics.search_tracker.get_search_context",
                return_value=ctx,
            ),
            patch(
                "local_deep_research.database.thread_metrics.metrics_writer",
                mock_writer,
            ),
        ):
            SearchTracker.record_search(
                engine_name="google",
                query="q",
                success=True,
            )
        call_obj = mock_session.add.call_args[0][0]
        assert call_obj.error_type is None
        assert call_obj.error_message is None


class TestRecordSearchSkipPaths:
    """Cover the early-return skip paths in record_search."""

    def test_skips_when_context_is_none(self):
        """No research context -> early return, metrics_writer never invoked."""
        mock_writer = MagicMock()
        with patch(
            "local_deep_research.metrics.search_tracker.get_search_context",
            return_value=None,
        ):
            with patch(
                "local_deep_research.database.thread_metrics.metrics_writer",
                mock_writer,
            ):
                SearchTracker.record_search(
                    engine_name="brave",
                    query="q",
                )
        mock_writer.get_session.assert_not_called()
        mock_writer.set_user_password.assert_not_called()

    def test_skips_when_username_missing(self):
        """Context without username -> warning logged, no session opened."""
        ctx = _base_context()
        ctx["username"] = None
        mock_writer = MagicMock()
        with patch(
            "local_deep_research.metrics.search_tracker.get_search_context",
            return_value=ctx,
        ):
            with patch(
                "local_deep_research.database.thread_metrics.metrics_writer",
                mock_writer,
            ):
                with patch(
                    "local_deep_research.metrics.search_tracker.logger"
                ) as mock_logger:
                    SearchTracker.record_search(
                        engine_name="brave",
                        query="q",
                    )
        mock_writer.get_session.assert_not_called()
        mock_logger.warning.assert_called_once()

    def test_skips_when_password_missing(self):
        """Context with username but no password -> early return before writer."""
        ctx = _base_context()
        ctx["user_password"] = None
        mock_writer = MagicMock()
        with patch(
            "local_deep_research.metrics.search_tracker.get_search_context",
            return_value=ctx,
        ):
            with patch(
                "local_deep_research.database.thread_metrics.metrics_writer",
                mock_writer,
            ):
                SearchTracker.record_search(
                    engine_name="brave",
                    query="q",
                )
        mock_writer.get_session.assert_not_called()
        mock_writer.set_user_password.assert_not_called()


class TestRecordSearchOuterException:
    """Cover the outer try/except in record_search (line 119)."""

    def test_outer_exception_caught_gracefully(self):
        """If get_search_context().get() raises, the outer except catches it."""
        # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: FIX_ASSERTION).
        ctx_dict = _base_context()
        bad_ctx = MagicMock()
        bad_ctx.__getitem__ = ctx_dict.__getitem__
        bad_ctx.get = Mock(
            side_effect=[
                ctx_dict["research_id"],
                ctx_dict["research_query"],
                ctx_dict["research_mode"],
                ctx_dict["research_phase"],
                ctx_dict["search_iteration"],
                RuntimeError("boom"),
            ]
        )

        with patch(
            "local_deep_research.metrics.search_tracker.get_search_context",
            return_value=bad_ctx,
        ):
            # Should not raise -- outer except handles it
            SearchTracker.record_search(
                engine_name="brave",
                query="q",
            )


# ===========================================================================
# get_search_metrics -- engine stats formatting, success_rate, truncation
# ===========================================================================


class TestGetSearchMetricsFormatting:
    """Cover data formatting paths in get_search_metrics."""

    def _make_stat(
        self, engine, call_count, avg_rt, total_res, avg_res, succ, err
    ):
        return SimpleNamespace(
            search_engine=engine,
            call_count=call_count,
            avg_response_time=avg_rt,
            total_results=total_res,
            avg_results_per_call=avg_res,
            success_count=succ,
            error_count=err,
        )

    def _make_call(self, engine, query, results_count, rt, status, ts):
        return SimpleNamespace(
            search_engine=engine,
            query=query,
            results_count=results_count,
            response_time_ms=rt,
            success_status=status,
            timestamp=ts,
        )

    def test_engine_stats_success_rate_calculation(self):
        """success_rate = success_count / call_count * 100."""
        mock_db, mock_session = _setup_db_and_session()
        q = _chain_query(mock_session)

        stat = self._make_stat("brave", 10, 200.0, 50, 5.0, 8, 2)
        q.all.side_effect = [[stat], []]

        tracker = SearchTracker(db=mock_db)
        result = tracker.get_search_metrics(period="all", research_mode="all")

        stats = result["search_engine_stats"]
        assert len(stats) == 1
        assert stats[0]["success_rate"] == 80.0
        assert stats[0]["error_count"] == 2
        assert stats[0]["avg_response_time"] == 200.0

    def test_engine_stats_zero_call_count(self):
        """When call_count is 0, success_rate should be 0."""
        mock_db, mock_session = _setup_db_and_session()
        q = _chain_query(mock_session)

        stat = self._make_stat("duckduckgo", 0, None, None, None, 0, None)
        q.all.side_effect = [[stat], []]

        tracker = SearchTracker(db=mock_db)
        result = tracker.get_search_metrics()
        stats = result["search_engine_stats"]
        assert stats[0]["success_rate"] == 0
        assert stats[0]["avg_response_time"] == 0
        assert stats[0]["total_results"] == 0

    def test_long_query_truncated_at_100_chars(self):
        """Queries longer than 100 chars should be truncated with '...'."""
        mock_db, mock_session = _setup_db_and_session()
        q = _chain_query(mock_session)

        long_query = "a" * 150
        call = self._make_call(
            "google", long_query, 5, 100, "success", "2025-01-01"
        )
        q.all.side_effect = [[], [call]]

        tracker = SearchTracker(db=mock_db)
        result = tracker.get_search_metrics()

        recent = result["recent_calls"]
        assert len(recent) == 1
        assert recent[0]["query"] == "a" * 100 + "..."

    def test_short_query_not_truncated(self):
        """Queries under 100 chars should remain as-is."""
        mock_db, mock_session = _setup_db_and_session()
        q = _chain_query(mock_session)

        short_query = "short query"
        call = self._make_call(
            "google", short_query, 5, 100, "success", "2025-01-01"
        )
        q.all.side_effect = [[], [call]]

        tracker = SearchTracker(db=mock_db)
        result = tracker.get_search_metrics()
        assert result["recent_calls"][0]["query"] == "short query"

    def test_none_query_in_recent_calls(self):
        """A None query should not crash the truncation logic."""
        mock_db, mock_session = _setup_db_and_session()
        q = _chain_query(mock_session)

        call = self._make_call("google", None, 0, 0, "error", "2025-01-01")
        q.all.side_effect = [[], [call]]

        tracker = SearchTracker(db=mock_db)
        result = tracker.get_search_metrics()
        assert result["recent_calls"][0]["query"] is None


# ===========================================================================
# get_research_search_metrics -- totals, exception
# ===========================================================================


class TestGetResearchSearchMetrics:
    """Cover totals calculation and exception path."""

    def _make_call(self, results, rt_ms, status):
        return SimpleNamespace(
            search_engine="brave",
            query="q",
            results_count=results,
            response_time_ms=rt_ms,
            success_status=status,
            timestamp="2025-01-01",
        )

    def _make_engine_stat(self, engine, count, avg_rt, total_res, succ):
        return SimpleNamespace(
            search_engine=engine,
            call_count=count,
            avg_response_time=avg_rt,
            total_results=total_res,
            success_count=succ,
        )

    def test_totals_calculated_correctly(self):
        """total_searches, total_results, avg_response_time, success_rate."""
        mock_db, mock_session = _setup_db_and_session()
        q = _chain_query(mock_session)

        calls = [
            self._make_call(10, 200, "success"),
            self._make_call(5, 300, "success"),
            self._make_call(0, 500, "error"),
        ]
        estat = self._make_engine_stat("brave", 3, 333.0, 15, 2)
        q.all.side_effect = [calls, [estat]]

        tracker = SearchTracker(db=mock_db)
        result = tracker.get_research_search_metrics("rid-1")

        assert result["total_searches"] == 3
        assert result["total_results"] == 15
        assert result["avg_response_time"] == 333  # round((200+300+500)/3)
        assert result["success_rate"] == pytest.approx(66.7, abs=0.1)
        assert len(result["search_calls"]) == 3
        assert len(result["engine_stats"]) == 1

    def test_empty_research_returns_zeros(self):
        """No search calls should produce all-zero totals."""
        mock_db, mock_session = _setup_db_and_session()
        q = _chain_query(mock_session)
        q.all.side_effect = [[], []]

        tracker = SearchTracker(db=mock_db)
        result = tracker.get_research_search_metrics("rid-empty")

        assert result["total_searches"] == 0
        assert result["total_results"] == 0
        assert result["avg_response_time"] == 0
        assert result["success_rate"] == 0

    def test_exception_returns_empty_dict(self):
        """DB exception should return the default empty structure."""
        mock_db, mock_session = _setup_db_and_session()
        mock_session.query.side_effect = Exception("DB error")

        tracker = SearchTracker(db=mock_db)
        result = tracker.get_research_search_metrics("rid-err")

        assert result["total_searches"] == 0
        assert result["search_calls"] == []
        assert result["engine_stats"] == []


# ===========================================================================
# get_search_time_series -- null fields, long query truncation, exception
# ===========================================================================


class TestGetSearchTimeSeries:
    def _make_call(self, ts, engine, results, rt, status, query):
        return SimpleNamespace(
            timestamp=ts,
            search_engine=engine,
            results_count=results,
            response_time_ms=rt,
            success_status=status,
            query=query,
        )

    def test_null_fields_handled(self):
        """None timestamp, results_count, response_time_ms should not crash."""
        mock_db, mock_session = _setup_db_and_session()
        q = _chain_query(mock_session)

        call = self._make_call(None, "google", None, None, "error", "short")
        q.all.return_value = [call]

        tracker = SearchTracker(db=mock_db)
        result = tracker.get_search_time_series()

        assert len(result) == 1
        assert result[0]["timestamp"] is None
        assert result[0]["results_count"] == 0
        assert result[0]["response_time_ms"] == 0

    def test_long_query_truncated_at_50_chars(self):
        """Queries > 50 chars should be truncated with '...'."""
        mock_db, mock_session = _setup_db_and_session()
        q = _chain_query(mock_session)

        long_q = "x" * 80
        call = self._make_call("2025-01-01", "brave", 5, 100, "success", long_q)
        q.all.return_value = [call]

        tracker = SearchTracker(db=mock_db)
        result = tracker.get_search_time_series()

        assert result[0]["query"] == "x" * 50 + "..."

    def test_short_query_unchanged(self):
        """Queries <= 50 chars should not be truncated."""
        mock_db, mock_session = _setup_db_and_session()
        q = _chain_query(mock_session)

        call = self._make_call(
            "2025-01-01", "brave", 5, 100, "success", "short q"
        )
        q.all.return_value = [call]

        tracker = SearchTracker(db=mock_db)
        result = tracker.get_search_time_series()

        assert result[0]["query"] == "short q"

    def test_exception_returns_empty_list(self):
        """DB exception should return []."""
        mock_db, mock_session = _setup_db_and_session()
        mock_session.query.side_effect = Exception("DB boom")

        tracker = SearchTracker(db=mock_db)
        result = tracker.get_search_time_series()

        assert result == []


# ===========================================================================
# get_search_tracker -- Flask credentials, no request context, exception
# ===========================================================================
