"""
Tests for coverage gaps in research_service.py.

Targets specific uncovered lines:
- run_research_process early termination (307-313)
- SettingsContext inner class (358-380)
- progress_callback termination check (428-434)
- LLM config error handling with ValueError raise (605-630)
- Search engine config error handling with ValueError raise (646-670)
- Search error classification (703-727)
- cleanup_research_resources test-mode delay path (1693-1699)
- handle_termination exception path (1797-1800)
- cancel_research database exception path (1860-1864)
- cancel_research outer exception path (1867-1871)
"""

from contextlib import contextmanager
from unittest.mock import MagicMock, patch

from loguru import logger

# Register MILESTONE log level used by progress_callback
try:
    logger.level("MILESTONE")
except ValueError:
    logger.level("MILESTONE", no=26)

# Module path constants
MODULE = "local_deep_research.web.services.research_service"
GLOBALS_MOD = "local_deep_research.web.routes.globals"
THREAD_SETTINGS_MOD = "local_deep_research.config.thread_settings"
SETTINGS_LOGGER_MOD = "local_deep_research.settings.logger"
QUEUE_PROC_MOD = "local_deep_research.web.queue.processor_v2"
ENV_REGISTRY_MOD = "local_deep_research.settings.env_registry"


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


def _fake_session_ctx(session=None):
    """Return a context manager factory that yields a mock session."""
    if session is None:
        session = MagicMock()

    @contextmanager
    def ctx(username=None):
        yield session

    return ctx


def _make_mock_research(status=None, research_meta=None):
    """Build a minimal ResearchHistory mock."""
    r = MagicMock()
    r.status = status
    r.research_meta = research_meta
    r.created_at = "2024-01-01T00:00:00"
    r.report_content = None
    return r


def _get_raw_run_research_process():
    """Get the unwrapped (no decorators) run_research_process function."""
    from local_deep_research.web.services.research_service import (
        run_research_process,
    )

    return run_research_process.__wrapped__.__wrapped__


def _base_run_patches(mock_session=None):
    """Return a dict of patches needed for run_research_process tests."""
    if mock_session is None:
        mock_session = MagicMock()
        mock_research = _make_mock_research(research_meta={})
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

    return {
        f"{MODULE}.get_user_db_session": _fake_session_ctx(mock_session),
        f"{MODULE}.handle_termination": MagicMock(),
        f"{MODULE}.cleanup_research_resources": MagicMock(),
        f"{MODULE}.set_search_context": MagicMock(),
        f"{MODULE}.SocketIOService": MagicMock(),
        f"{MODULE}.calculate_duration": MagicMock(return_value=5),
        f"{MODULE}.ErrorReportGenerator": MagicMock(
            return_value=MagicMock(
                generate_error_report=MagicMock(return_value="error report")
            )
        ),
        f"{GLOBALS_MOD}.is_termination_requested": MagicMock(
            return_value=False
        ),
        f"{GLOBALS_MOD}.is_research_active": MagicMock(return_value=False),
        f"{GLOBALS_MOD}.update_progress_and_check_active": MagicMock(
            return_value=(5, True)
        ),
        f"{SETTINGS_LOGGER_MOD}.log_settings": MagicMock(),
        f"{THREAD_SETTINGS_MOD}.set_settings_context": MagicMock(),
        f"{QUEUE_PROC_MOD}.queue_processor": MagicMock(),
    }


# ---------------------------------------------------------------------------
# 1. run_research_process early termination (lines 307-313)
# ---------------------------------------------------------------------------


class TestRunResearchEarlyTermination:
    """When termination is requested before the research even starts,
    the function should clean up and return immediately."""

    def test_returns_immediately_when_terminated_before_start(self):
        """If is_termination_requested returns True on entry, the function
        calls cleanup_research_resources and returns without running research."""
        func = _get_raw_run_research_process()
        mock_cleanup = MagicMock()

        patches = _base_run_patches()
        # Override: termination already requested
        patches[f"{GLOBALS_MOD}.is_termination_requested"] = MagicMock(
            return_value=True
        )
        patches[f"{MODULE}.cleanup_research_resources"] = mock_cleanup

        # AdvancedSearchSystem should never be instantiated
        patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock()

        stack = []
        for target, mock_obj in patches.items():
            p = patch(target, mock_obj)
            stack.append(p)
            p.start()
        try:
            func(1, "test query", "quick", username="testuser")
        finally:
            for p in stack:
                p.stop()

        # cleanup_research_resources must have been called
        mock_cleanup.assert_called_once_with(1, "testuser", user_password=None)
        # AdvancedSearchSystem should not have been created
        patches[f"{MODULE}.AdvancedSearchSystem"].assert_not_called()


# ---------------------------------------------------------------------------
# 2. SettingsContext inner class (lines 358-380)
# ---------------------------------------------------------------------------


class TestSettingsContextInnerClass:
    """The SettingsContext inner class in run_research_process extracts
    values from a settings snapshot and provides get_setting()."""

    def test_settings_context_extracts_dict_values(self):
        """When snapshot items are full setting objects with 'value' keys,
        SettingsContext should extract just the value."""
        func = _get_raw_run_research_process()

        captured_contexts = []
        original_set = MagicMock()

        def capture_settings_context(ctx):
            captured_contexts.append(ctx)
            original_set(ctx)

        mock_system = MagicMock()
        mock_system.analyze_topic.return_value = {
            "findings": "test",
            "formatted_findings": "test result",
        }

        patches = _base_run_patches()
        patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock(
            return_value=mock_system
        )
        patches[f"{THREAD_SETTINGS_MOD}.set_settings_context"] = (
            capture_settings_context
        )

        snapshot = {
            "llm.model": {"value": "gpt-4", "type": "string"},
            "search.max_results": {"value": 10, "type": "int"},
            "plain_key": "just_a_value",
        }

        stack = []
        for target, mock_obj in patches.items():
            p = patch(target, mock_obj)
            stack.append(p)
            p.start()
        try:
            func(
                1,
                "test",
                "quick",
                username="user1",
                settings_snapshot=snapshot,
            )
        finally:
            for p in stack:
                p.stop()

        assert len(captured_contexts) == 1
        ctx = captured_contexts[0]
        # Full setting objects should have their values extracted
        assert ctx.get_setting("llm.model") == "gpt-4"
        assert ctx.get_setting("search.max_results") == 10
        # Plain values should be kept as-is
        assert ctx.get_setting("plain_key") == "just_a_value"

    def test_settings_context_returns_default_for_missing_key(self):
        """get_setting returns default when key is not in snapshot."""
        func = _get_raw_run_research_process()

        captured_contexts = []

        def capture(ctx):
            captured_contexts.append(ctx)

        mock_system = MagicMock()
        mock_system.analyze_topic.return_value = {
            "findings": "test",
            "formatted_findings": "result",
        }

        patches = _base_run_patches()
        patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock(
            return_value=mock_system
        )
        patches[f"{THREAD_SETTINGS_MOD}.set_settings_context"] = capture

        stack = []
        for target, mock_obj in patches.items():
            p = patch(target, mock_obj)
            stack.append(p)
            p.start()
        try:
            func(
                1,
                "test",
                "quick",
                username="user1",
                settings_snapshot={},
            )
        finally:
            for p in stack:
                p.stop()

        ctx = captured_contexts[0]
        assert ctx.get_setting("nonexistent.key", "fallback") == "fallback"
        assert ctx.get_setting("nonexistent.key") is None


# ---------------------------------------------------------------------------
# 3. progress_callback termination check (lines 428-434)
# ---------------------------------------------------------------------------


class TestProgressCallbackTerminationCheck:
    """progress_callback checks is_termination_requested on every call and
    raises ResearchTerminatedException if termination was requested."""

    def test_progress_callback_raises_on_termination(self):
        """When termination is requested mid-research, progress_callback
        should call handle_termination and raise ResearchTerminatedException."""
        from local_deep_research.exceptions import (
            ResearchTerminatedException,
        )

        func = _get_raw_run_research_process()

        call_count = [0]
        mock_handle = MagicMock()

        def termination_check(rid):
            call_count[0] += 1
            # Return True on second call (first call is the early-exit check,
            # second call is from inside progress_callback)
            return call_count[0] >= 2

        mock_system = MagicMock()
        # analyze_topic will trigger progress_callback which triggers termination
        mock_system.analyze_topic.side_effect = ResearchTerminatedException(
            "terminated"
        )

        patches = _base_run_patches()
        patches[f"{GLOBALS_MOD}.is_termination_requested"] = termination_check
        patches[f"{MODULE}.handle_termination"] = mock_handle
        patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock(
            return_value=mock_system
        )

        stack = []
        for target, mock_obj in patches.items():
            p = patch(target, mock_obj)
            stack.append(p)
            p.start()
        try:
            # The function catches ResearchTerminatedException internally
            func(1, "test", "quick", username="user1", settings_snapshot={})
        finally:
            for p in stack:
                p.stop()

        # The function should not propagate ResearchTerminatedException
        # (it is caught in the except ResearchTerminatedException block)


# ---------------------------------------------------------------------------
# 4. LLM config error handling (lines 605-630)
# ---------------------------------------------------------------------------


class TestLLMConfigErrorRaisesValueError:
    """When get_llm raises an error containing config-related keywords,
    the code re-raises as ValueError('LLM Configuration Error: ...')."""

    def _run_with_llm_error(self, error_msg, model="gpt-4", provider="openai"):
        """Run research with get_llm raising an exception containing error_msg."""
        func = _get_raw_run_research_process()
        mock_qp = MagicMock()

        patches = _base_run_patches()
        patches[f"{MODULE}.get_llm"] = MagicMock(
            side_effect=Exception(error_msg)
        )
        patches[f"{QUEUE_PROC_MOD}.queue_processor"] = mock_qp

        stack = []
        for target, mock_obj in patches.items():
            p = patch(target, mock_obj)
            stack.append(p)
            p.start()
        try:
            func(
                1,
                "test",
                "quick",
                username="user1",
                model=model,
                model_provider=provider,
                settings_snapshot={},
            )
        finally:
            for p in stack:
                p.stop()

        return mock_qp

    def test_llamacpp_keyword_triggers_config_error(self):
        """'llamacpp' in error message triggers ValueError with config prefix."""
        mock_qp = self._run_with_llm_error("llamacpp model failed to load")
        # Error is caught by outer handler and queued
        mock_qp.queue_error_update.assert_called_once()
        call_kwargs = mock_qp.queue_error_update.call_args
        assert "LLM Configuration Error" in str(call_kwargs)

    def test_model_path_keyword_triggers_config_error(self):
        """'model path' in error message triggers ValueError with config prefix."""
        mock_qp = self._run_with_llm_error("model path /foo/bar does not exist")
        mock_qp.queue_error_update.assert_called_once()
        assert "LLM Configuration Error" in str(
            mock_qp.queue_error_update.call_args
        )

    def test_gguf_keyword_triggers_config_error(self):
        """'.gguf' in error message triggers ValueError with config prefix."""
        mock_qp = self._run_with_llm_error("please provide a valid .gguf file")
        mock_qp.queue_error_update.assert_called_once()
        assert "LLM Configuration Error" in str(
            mock_qp.queue_error_update.call_args
        )

    def test_non_config_llm_error_re_raises_directly(self):
        """Errors without config keywords are re-raised as-is (not wrapped)."""
        mock_qp = self._run_with_llm_error(
            "unexpected null pointer in inference"
        )
        mock_qp.queue_error_update.assert_called_once()
        # Should NOT contain "LLM Configuration Error" prefix
        error_str = str(mock_qp.queue_error_update.call_args)
        assert "LLM Configuration Error" not in error_str


# ---------------------------------------------------------------------------
# 5. Search engine config error handling (lines 646-670)
# ---------------------------------------------------------------------------


class TestSearchEngineConfigError:
    """When get_search raises with config keywords, the code re-raises as
    ValueError('Search Engine Configuration Error ...')."""

    def _run_with_search_error(self, error_msg, search_engine="searxng"):
        func = _get_raw_run_research_process()
        mock_qp = MagicMock()

        patches = _base_run_patches()
        patches[f"{MODULE}.get_search"] = MagicMock(
            side_effect=Exception(error_msg)
        )
        patches[f"{QUEUE_PROC_MOD}.queue_processor"] = mock_qp

        stack = []
        for target, mock_obj in patches.items():
            p = patch(target, mock_obj)
            stack.append(p)
            p.start()
        try:
            func(
                1,
                "test",
                "quick",
                username="user1",
                search_engine=search_engine,
                settings_snapshot={},
            )
        finally:
            for p in stack:
                p.stop()

        return mock_qp

    def test_searxng_keyword_triggers_config_error(self):
        """'searxng' in error message triggers Search Engine Configuration Error."""
        mock_qp = self._run_with_search_error("SearXNG instance unreachable")
        mock_qp.queue_error_update.assert_called_once()
        assert "Search Engine Configuration Error" in str(
            mock_qp.queue_error_update.call_args
        )

    def test_api_key_keyword_triggers_config_error(self):
        """'api_key' in error message triggers Search Engine Configuration Error."""
        mock_qp = self._run_with_search_error(
            "Missing api_key for search provider"
        )
        mock_qp.queue_error_update.assert_called_once()
        assert "Search Engine Configuration Error" in str(
            mock_qp.queue_error_update.call_args
        )

    def test_connection_keyword_triggers_config_error(self):
        """'connection' in error message triggers Search Engine Configuration Error."""
        mock_qp = self._run_with_search_error(
            "Connection refused by search backend"
        )
        mock_qp.queue_error_update.assert_called_once()
        assert "Search Engine Configuration Error" in str(
            mock_qp.queue_error_update.call_args
        )

    def test_non_config_search_error_re_raises(self):
        """Errors without config keywords are re-raised as-is."""
        mock_qp = self._run_with_search_error("some random internal crash")
        mock_qp.queue_error_update.assert_called_once()
        error_str = str(mock_qp.queue_error_update.call_args)
        assert "Search Engine Configuration Error" not in error_str


# ---------------------------------------------------------------------------
# 6. Search error classification (lines 703-727)
# ---------------------------------------------------------------------------


class TestSearchErrorClassification:
    """system.analyze_topic() failures are classified by HTTP status code
    or connection error pattern and re-raised with improved messages."""

    def _run_with_analyze_error(self, error_msg):
        func = _get_raw_run_research_process()
        mock_qp = MagicMock()

        mock_system = MagicMock()
        mock_system.analyze_topic.side_effect = Exception(error_msg)

        patches = _base_run_patches()
        patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock(
            return_value=mock_system
        )
        patches[f"{QUEUE_PROC_MOD}.queue_processor"] = mock_qp

        stack = []
        for target, mock_obj in patches.items():
            p = patch(target, mock_obj)
            stack.append(p)
            p.start()
        try:
            func(
                1,
                "test",
                "quick",
                username="user1",
                settings_snapshot={},
            )
        finally:
            for p in stack:
                p.stop()

        return mock_qp

    def test_503_classified_as_ollama_unavailable(self):
        """HTTP 503 is classified as ollama_unavailable by the inner handler
        (lines 709-711), then the outer handler (lines 1469-1473) rewrites it
        to a user-friendly message with a solution hint."""
        mock_qp = self._run_with_analyze_error(
            "Request failed with status code: 503"
        )
        mock_qp.queue_error_update.assert_called_once()
        call_kwargs = mock_qp.queue_error_update.call_args[1]
        # Outer handler replaces the error type marker with user-friendly text
        assert (
            "Ollama AI service is unavailable" in call_kwargs["error_message"]
        )
        assert "solution" in call_kwargs["metadata"]
        assert "ollama serve" in call_kwargs["metadata"]["solution"]

    def test_404_classified_as_model_not_found(self):
        """HTTP 404 is classified as model_not_found by the inner handler
        (lines 712-714), then rewritten by outer handler (lines 1474-1478)."""
        mock_qp = self._run_with_analyze_error(
            "Request failed with status code: 404"
        )
        mock_qp.queue_error_update.assert_called_once()
        call_kwargs = mock_qp.queue_error_update.call_args[1]
        assert "model not found" in call_kwargs["error_message"].lower()
        assert "solution" in call_kwargs["metadata"]
        assert "ollama pull" in call_kwargs["metadata"]["solution"]

    def test_other_status_code_classified_as_api_error(self):
        """Other HTTP status codes are classified as api_error by lines 715-719,
        then matched by 'Error type: api_error' in the outer handler (line 1484)."""
        mock_qp = self._run_with_analyze_error(
            "Request failed with status code: 429"
        )
        mock_qp.queue_error_update.assert_called_once()
        call_kwargs = mock_qp.queue_error_update.call_args[1]
        assert "solution" in call_kwargs["metadata"]
        assert "API" in call_kwargs["metadata"]["solution"]

    def test_connection_error_classified_correctly(self):
        """Connection errors (case-insensitive) are classified as connection_error
        by lines 720-722, then rewritten by outer handler (lines 1479-1483)."""
        mock_qp = self._run_with_analyze_error(
            "Connection refused to localhost:11434"
        )
        mock_qp.queue_error_update.assert_called_once()
        call_kwargs = mock_qp.queue_error_update.call_args[1]
        assert "Connection error" in call_kwargs["error_message"]
        assert "solution" in call_kwargs["metadata"]

    def test_unknown_error_preserves_original_message(self):
        """Errors without known patterns keep error_type=unknown in the re-raised
        exception, and the outer handler does not match any rewrite pattern."""
        mock_qp = self._run_with_analyze_error(
            "something completely unexpected happened"
        )
        mock_qp.queue_error_update.assert_called_once()
        call_kwargs = mock_qp.queue_error_update.call_args[1]
        # No solution context is added for unknown errors
        assert "solution" not in call_kwargs["metadata"]


# ---------------------------------------------------------------------------
# 7. cleanup_research_resources test-mode delay (lines 1693-1699)
# ---------------------------------------------------------------------------


class TestCleanupResearchResourcesTestMode:
    """When is_test_mode() returns True, cleanup adds a 5-second delay."""

    @patch(f"{MODULE}.SocketIOService")
    @patch(f"{GLOBALS_MOD}.cleanup_research")
    @patch(f"{QUEUE_PROC_MOD}.queue_processor")
    @patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=True)
    def test_test_mode_sleeps_before_cleanup(
        self, mock_test_mode, mock_qp, mock_cleanup, mock_socket
    ):
        """In test mode, time.sleep(5) is called before continuing cleanup."""
        from local_deep_research.web.services.research_service import (
            cleanup_research_resources,
        )

        with patch(f"{MODULE}.time.sleep") as mock_sleep:
            cleanup_research_resources(42, username="user1")

        mock_sleep.assert_called_once_with(5)
        mock_test_mode.assert_called_once()

    @patch(f"{MODULE}.SocketIOService")
    @patch(f"{GLOBALS_MOD}.cleanup_research")
    @patch(f"{QUEUE_PROC_MOD}.queue_processor")
    @patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False)
    def test_non_test_mode_does_not_sleep(
        self, mock_test_mode, mock_qp, mock_cleanup, mock_socket
    ):
        """Outside test mode, no delay is added."""
        from local_deep_research.web.services.research_service import (
            cleanup_research_resources,
        )

        with patch(f"{MODULE}.time.sleep") as mock_sleep:
            cleanup_research_resources(42, username="user1")

        mock_sleep.assert_not_called()


# ---------------------------------------------------------------------------
# 8. handle_termination exception path (lines 1797-1800)
# ---------------------------------------------------------------------------


class TestHandleTerminationExceptionPath:
    """When queue_processor.queue_error_update raises, handle_termination
    catches the exception and still proceeds to cleanup."""

    @patch(f"{MODULE}.cleanup_research_resources")
    def test_queue_error_exception_is_caught_and_cleanup_still_runs(
        self, mock_cleanup
    ):
        """If queue_error_update raises, the exception is caught and
        cleanup_research_resources is still called."""
        from local_deep_research.web.services.research_service import (
            handle_termination,
        )

        mock_qp = MagicMock()
        mock_qp.queue_error_update.side_effect = RuntimeError(
            "queue processor down"
        )

        with patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp):
            # Should not raise
            handle_termination(99, username="user1")

        # cleanup_research_resources must still be called despite the error
        mock_cleanup.assert_called_once_with(99, "user1")

    @patch(f"{MODULE}.cleanup_research_resources")
    def test_successful_termination_queues_suspended_status(self, mock_cleanup):
        """On success, handle_termination queues a SUSPENDED status update."""
        from local_deep_research.constants import ResearchStatus
        from local_deep_research.web.services.research_service import (
            handle_termination,
        )

        mock_qp = MagicMock()

        with patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp):
            handle_termination(55, username="user1")

        mock_qp.queue_error_update.assert_called_once()
        call_kwargs = mock_qp.queue_error_update.call_args[1]
        assert call_kwargs["status"] == ResearchStatus.SUSPENDED
        assert call_kwargs["username"] == "user1"
        assert call_kwargs["research_id"] == 55
        mock_cleanup.assert_called_once_with(55, "user1")


# ---------------------------------------------------------------------------
# 9. cancel_research database exception path (lines 1860-1864)
# ---------------------------------------------------------------------------


class TestCancelResearchDbException:
    """When the database query in the inactive-research branch of
    cancel_research raises, the function returns False."""

    def test_db_exception_returns_false(self):
        """Database errors in the non-active path return False."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

        with patch(f"{GLOBALS_MOD}.set_termination_flag"):
            with patch(f"{GLOBALS_MOD}.is_research_active", return_value=False):
                with patch(
                    f"{MODULE}.get_user_db_session",
                    side_effect=RuntimeError("db connection lost"),
                ):
                    result = cancel_research(123, username="user1")

        assert result is False

    def test_db_not_found_returns_false(self):
        """When research is not found in database, returns False."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

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

        with patch(f"{GLOBALS_MOD}.set_termination_flag"):
            with patch(f"{GLOBALS_MOD}.is_research_active", return_value=False):
                with patch(
                    f"{MODULE}.get_user_db_session",
                    _fake_session_ctx(mock_session),
                ):
                    result = cancel_research(123, username="user1")

        assert result is False


# ---------------------------------------------------------------------------
# 10. cancel_research outer exception path (lines 1867-1871)
# ---------------------------------------------------------------------------


class TestCancelResearchOuterException:
    """When an unexpected exception occurs in cancel_research's outer try
    block (e.g., set_termination_flag itself raises), the function catches
    the exception and returns False."""

    def test_set_termination_flag_raises_returns_false(self):
        """If set_termination_flag raises, cancel_research returns False."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

        with patch(
            f"{GLOBALS_MOD}.set_termination_flag",
            side_effect=RuntimeError("unexpected failure"),
        ):
            result = cancel_research(456, username="user1")

        assert result is False

    def test_is_research_active_raises_returns_false(self):
        """If is_research_active raises, cancel_research returns False."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

        with patch(f"{GLOBALS_MOD}.set_termination_flag"):
            with patch(
                f"{GLOBALS_MOD}.is_research_active",
                side_effect=RuntimeError("state corrupted"),
            ):
                result = cancel_research(789, username="user1")

        assert result is False

    def test_active_research_calls_handle_termination_and_returns_true(self):
        """When research IS active, cancel_research calls handle_termination
        and returns True."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

        mock_handle = MagicMock()

        with patch(f"{GLOBALS_MOD}.set_termination_flag"):
            with patch(f"{GLOBALS_MOD}.is_research_active", return_value=True):
                with patch(f"{MODULE}.handle_termination", mock_handle):
                    result = cancel_research(10, username="user1")

        assert result is True
        mock_handle.assert_called_once_with(10, "user1")
