"""
Comprehensive coverage tests for research_service.py targeting uncovered
code paths in run_research_process, start_research_process,
cleanup_research_resources, handle_termination, cancel_research,
and helper functions.
"""

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

import pytest
from loguru import logger

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

# Module path shorthand
RS = "local_deep_research.web.services.research_service"


# ---------------------------------------------------------------------------
# Helper to build a fake db session context manager
# ---------------------------------------------------------------------------
def _fake_session_ctx(session):
    @contextmanager
    def ctx(username=None):
        yield session

    return ctx


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,
    )

    # Unwrap @log_for_research and @thread_cleanup decorators
    return run_research_process.__wrapped__.__wrapped__


# ---------------------------------------------------------------------------
# _parse_research_metadata
# ---------------------------------------------------------------------------
class TestParseResearchMetadata:
    def test_dict_input_returns_copy(self):
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        original = {"key": "value"}
        result = _parse_research_metadata(original)
        assert result == original and result is not original

    def test_valid_json_string(self):
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        assert _parse_research_metadata('{"a":1}') == {"a": 1}

    def test_invalid_json_returns_empty(self):
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        assert _parse_research_metadata("bad{") == {}

    def test_none_returns_empty(self):
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        assert _parse_research_metadata(None) == {}

    def test_int_returns_empty(self):
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        assert _parse_research_metadata(42) == {}


# ---------------------------------------------------------------------------
# _generate_report_path
# ---------------------------------------------------------------------------
class TestGenerateReportPath:
    def test_returns_path_with_md_suffix(self):
        from local_deep_research.web.services.research_service import (
            _generate_report_path,
        )

        p = _generate_report_path("test")
        assert isinstance(p, Path)
        assert p.suffix == ".md"

    def test_different_queries_different_hashes(self):
        from local_deep_research.web.services.research_service import (
            _generate_report_path,
        )

        p1 = _generate_report_path("query one")
        p2 = _generate_report_path("query two")
        assert p1.stem.split("_")[2] != p2.stem.split("_")[2]


# ---------------------------------------------------------------------------
# get_citation_formatter
# ---------------------------------------------------------------------------
class TestGetCitationFormatter:
    @patch(f"{RS}.CitationFormatter")
    @patch(f"{RS}.CitationMode")
    def test_no_hyperlinks_mode(self, mock_mode, mock_fmt):
        with patch(
            "local_deep_research.config.search_config.get_setting_from_snapshot",
            return_value="no_hyperlinks",
        ):
            from local_deep_research.web.services.research_service import (
                get_citation_formatter,
            )

            get_citation_formatter()
        mock_fmt.assert_called_once_with(mode=mock_mode.NO_HYPERLINKS)

    @patch(f"{RS}.CitationFormatter")
    @patch(f"{RS}.CitationMode")
    def test_unknown_format_defaults(self, mock_mode, mock_fmt):
        with patch(
            "local_deep_research.config.search_config.get_setting_from_snapshot",
            return_value="unknown_format",
        ):
            from local_deep_research.web.services.research_service import (
                get_citation_formatter,
            )

            get_citation_formatter()
        mock_fmt.assert_called_once_with(mode=mock_mode.NUMBER_HYPERLINKS)


# ---------------------------------------------------------------------------
# export_report_to_memory
# ---------------------------------------------------------------------------
class TestExportReportToMemory:
    def test_success(self):
        mock_exporter = MagicMock()
        mock_result = SimpleNamespace(
            content=b"data", filename="r.pdf", mimetype="application/pdf"
        )
        mock_exporter.export.return_value = mock_result

        mock_registry = MagicMock()
        mock_registry.get_exporter.return_value = mock_exporter

        with (
            patch(
                "local_deep_research.exporters.ExporterRegistry", mock_registry
            ),
            patch("local_deep_research.exporters.ExportOptions", MagicMock()),
        ):
            from local_deep_research.web.services.research_service import (
                export_report_to_memory,
            )

            content, fname, mime = export_report_to_memory(
                "# R", "PDF", title="T"
            )
        assert content == b"data"
        mock_registry.get_exporter.assert_called_once_with("pdf")

    def test_unsupported_format(self):
        mock_registry = MagicMock()
        mock_registry.get_exporter.return_value = None
        mock_registry.get_available_formats.return_value = ["pdf"]

        with (
            patch(
                "local_deep_research.exporters.ExporterRegistry", mock_registry
            ),
            patch("local_deep_research.exporters.ExportOptions", MagicMock()),
        ):
            from local_deep_research.web.services.research_service import (
                export_report_to_memory,
            )

            with pytest.raises(ValueError, match="Unsupported"):
                export_report_to_memory("# R", "xyz")


# ---------------------------------------------------------------------------
# save_research_strategy / get_research_strategy
# ---------------------------------------------------------------------------
class TestSaveResearchStrategy:
    @patch(f"{RS}.get_user_db_session")
    @patch(f"{RS}.ResearchStrategy")
    def test_create_new(self, mock_cls, mock_sess):
        session = MagicMock()
        session.query.return_value.filter_by.return_value.first.return_value = (
            None
        )
        mock_sess.side_effect = _fake_session_ctx(session)

        from local_deep_research.web.services.research_service import (
            save_research_strategy,
        )

        save_research_strategy(1, "detailed", username="u")
        session.add.assert_called_once()
        session.commit.assert_called_once()

    @patch(f"{RS}.get_user_db_session")
    @patch(f"{RS}.ResearchStrategy")
    def test_update_existing(self, mock_cls, mock_sess):
        session = MagicMock()
        existing = MagicMock()
        session.query.return_value.filter_by.return_value.first.return_value = (
            existing
        )
        mock_sess.side_effect = _fake_session_ctx(session)

        from local_deep_research.web.services.research_service import (
            save_research_strategy,
        )

        save_research_strategy(1, "new_strat", username="u")
        assert existing.strategy_name == "new_strat"
        session.add.assert_not_called()

    @patch(f"{RS}.get_user_db_session")
    def test_exception_handled(self, mock_sess):
        mock_sess.side_effect = RuntimeError("fail")
        from local_deep_research.web.services.research_service import (
            save_research_strategy,
        )

        save_research_strategy(1, "x")  # should not raise


class TestGetResearchStrategy:
    @patch(f"{RS}.get_user_db_session")
    def test_found(self, mock_sess):
        session = MagicMock()
        obj = MagicMock()
        obj.strategy_name = "deep"
        session.query.return_value.filter_by.return_value.first.return_value = (
            obj
        )
        mock_sess.side_effect = _fake_session_ctx(session)

        from local_deep_research.web.services.research_service import (
            get_research_strategy,
        )

        assert get_research_strategy(1, username="u") == "deep"

    @patch(f"{RS}.get_user_db_session")
    def test_not_found(self, mock_sess):
        session = MagicMock()
        session.query.return_value.filter_by.return_value.first.return_value = (
            None
        )
        mock_sess.side_effect = _fake_session_ctx(session)

        from local_deep_research.web.services.research_service import (
            get_research_strategy,
        )

        assert get_research_strategy(1, username="u") is None

    @patch(f"{RS}.get_user_db_session")
    def test_exception_returns_none(self, mock_sess):
        mock_sess.side_effect = RuntimeError("fail")
        from local_deep_research.web.services.research_service import (
            get_research_strategy,
        )

        assert get_research_strategy(1) is None


# ---------------------------------------------------------------------------
# start_research_process
# ---------------------------------------------------------------------------
class TestStartResearchProcess:
    @patch(f"{RS}.thread_context", return_value={"ctx": True})
    @patch(f"{RS}.thread_with_app_context", side_effect=lambda f: f)
    @patch(f"{RS}._global_research_semaphore")
    def test_starts_thread_and_registers(
        self, mock_sem, mock_app_ctx, mock_tctx
    ):
        with patch(
            "local_deep_research.web.routes.globals.set_active_research"
        ) as mock_set:
            from local_deep_research.web.services.research_service import (
                start_research_process,
            )

            callback = MagicMock()
            thread = start_research_process(
                research_id="r1",
                query="test",
                mode="quick",
                run_research_callback=callback,
            )
            assert isinstance(thread, threading.Thread)
            assert thread.daemon is True
            mock_set.assert_called_once()
            args = mock_set.call_args
            assert args[0][0] == "r1"
            info = args[0][1]
            assert info["progress"] == 0
            assert info["status"] == "in_progress"


# ---------------------------------------------------------------------------
# cleanup_research_resources
# ---------------------------------------------------------------------------
class TestCleanupResearchResources:
    @patch(f"{RS}.SocketIOService")
    @patch(f"{RS}._last_emit_lock", threading.Lock())
    @patch(f"{RS}._last_emit_times", {"r1": 1.0})
    @patch("local_deep_research.web.routes.globals.cleanup_research")
    @patch(
        "local_deep_research.settings.env_registry.is_test_mode",
        return_value=False,
    )
    def test_cleanup_notifies_and_emits(
        self, mock_test_mode, mock_cleanup, mock_socket_cls
    ):
        mock_qp = MagicMock()
        with patch(
            "local_deep_research.web.queue.processor_v2.queue_processor",
            mock_qp,
        ):
            from local_deep_research.web.services.research_service import (
                cleanup_research_resources,
            )

            cleanup_research_resources("r1", username="alice")

        mock_qp.notify_research_completed.assert_called_once_with(
            "alice", "r1", user_password=None
        )
        mock_cleanup.assert_called_once_with("r1")

    @patch(f"{RS}.SocketIOService")
    @patch(f"{RS}._last_emit_lock", threading.Lock())
    @patch(f"{RS}._last_emit_times", {})
    @patch("local_deep_research.web.routes.globals.cleanup_research")
    @patch(
        "local_deep_research.settings.env_registry.is_test_mode",
        return_value=False,
    )
    def test_cleanup_without_username(
        self, mock_test_mode, mock_cleanup, mock_socket_cls
    ):
        mock_qp = MagicMock()
        with patch(
            "local_deep_research.web.queue.processor_v2.queue_processor",
            mock_qp,
        ):
            from local_deep_research.web.services.research_service import (
                cleanup_research_resources,
            )

            cleanup_research_resources("r2", username=None)

        mock_qp.notify_research_completed.assert_not_called()

    @patch(f"{RS}.SocketIOService")
    @patch(f"{RS}._last_emit_lock", threading.Lock())
    @patch(f"{RS}._last_emit_times", {})
    @patch("local_deep_research.web.routes.globals.cleanup_research")
    @patch(
        "local_deep_research.settings.env_registry.is_test_mode",
        return_value=False,
    )
    def test_cleanup_socket_error_handled(
        self, mock_test_mode, mock_cleanup, mock_socket_cls
    ):
        """Socket errors in cleanup should not raise."""
        mock_socket_cls.return_value.emit_to_subscribers.side_effect = (
            RuntimeError("socket fail")
        )
        mock_qp = MagicMock()
        with patch(
            "local_deep_research.web.queue.processor_v2.queue_processor",
            mock_qp,
        ):
            from local_deep_research.web.services.research_service import (
                cleanup_research_resources,
            )

            # Should not raise
            cleanup_research_resources("r3", username="u")


# ---------------------------------------------------------------------------
# handle_termination
# ---------------------------------------------------------------------------
class TestHandleTermination:
    @patch(f"{RS}.cleanup_research_resources")
    def test_queues_suspension_update(self, mock_cleanup):
        mock_qp = MagicMock()
        with patch(
            "local_deep_research.web.queue.processor_v2.queue_processor",
            mock_qp,
        ):
            from local_deep_research.web.services.research_service import (
                handle_termination,
            )

            handle_termination("r1", username="alice")

        mock_qp.queue_error_update.assert_called_once()
        kwargs = mock_qp.queue_error_update.call_args[1]
        assert kwargs["username"] == "alice"
        assert kwargs["research_id"] == "r1"
        assert kwargs["status"] == "suspended"
        mock_cleanup.assert_called_once_with("r1", "alice")

    @patch(f"{RS}.cleanup_research_resources")
    def test_exception_in_queue_handled(self, mock_cleanup):
        mock_qp = MagicMock()
        mock_qp.queue_error_update.side_effect = RuntimeError("fail")
        with patch(
            "local_deep_research.web.queue.processor_v2.queue_processor",
            mock_qp,
        ):
            from local_deep_research.web.services.research_service import (
                handle_termination,
            )

            # Should not raise
            handle_termination("r1", username="u")
        mock_cleanup.assert_called_once()


# ---------------------------------------------------------------------------
# cancel_research
# ---------------------------------------------------------------------------
class TestCancelResearch:
    @patch(f"{RS}.handle_termination")
    def test_active_research_cancelled(self, mock_handle):
        with (
            patch(
                "local_deep_research.web.routes.globals.set_termination_flag"
            ) as mock_flag,
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
        ):
            from local_deep_research.web.services.research_service import (
                cancel_research,
            )

            result = cancel_research("r1", "alice")
        assert result is True
        mock_flag.assert_called_once_with("r1")
        mock_handle.assert_called_once_with("r1", "alice")

    @patch(f"{RS}.get_user_db_session")
    @patch(f"{RS}.handle_termination")
    def test_inactive_completed_research(self, mock_handle, mock_sess):
        session = MagicMock()
        research = MagicMock()
        research.status = "completed"
        session.query.return_value.filter_by.return_value.first.return_value = (
            research
        )
        mock_sess.side_effect = _fake_session_ctx(session)

        with (
            patch(
                "local_deep_research.web.routes.globals.set_termination_flag"
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=False,
            ),
        ):
            from local_deep_research.web.services.research_service import (
                cancel_research,
            )

            result = cancel_research("r1", "alice")
        assert result is True
        # Already in terminal state, should not call handle_termination
        mock_handle.assert_not_called()

    @patch(f"{RS}.get_user_db_session")
    @patch(f"{RS}.handle_termination")
    def test_inactive_not_found(self, mock_handle, mock_sess):
        session = MagicMock()
        session.query.return_value.filter_by.return_value.first.return_value = (
            None
        )
        mock_sess.side_effect = _fake_session_ctx(session)

        with (
            patch(
                "local_deep_research.web.routes.globals.set_termination_flag"
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=False,
            ),
        ):
            from local_deep_research.web.services.research_service import (
                cancel_research,
            )

            result = cancel_research("r1", "alice")
        assert result is False

    @patch(f"{RS}.get_user_db_session")
    @patch(f"{RS}.handle_termination")
    def test_inactive_in_progress_suspended(self, mock_handle, mock_sess):
        """Research exists in DB in_progress state, not in active dict -> suspend it."""
        session = MagicMock()
        research = MagicMock()
        research.status = "in_progress"
        session.query.return_value.filter_by.return_value.first.return_value = (
            research
        )
        mock_sess.side_effect = _fake_session_ctx(session)

        with (
            patch(
                "local_deep_research.web.routes.globals.set_termination_flag"
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=False,
            ),
        ):
            from local_deep_research.web.services.research_service import (
                cancel_research,
            )

            result = cancel_research("r1", "alice")
        assert result is True
        assert research.status == "suspended"
        session.commit.assert_called_once()

    @patch(f"{RS}.get_user_db_session")
    @patch(f"{RS}.handle_termination")
    def test_db_exception_returns_false(self, mock_handle, mock_sess):
        mock_sess.side_effect = RuntimeError("db fail")

        with (
            patch(
                "local_deep_research.web.routes.globals.set_termination_flag"
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=False,
            ),
        ):
            from local_deep_research.web.services.research_service import (
                cancel_research,
            )

            result = cancel_research("r1", "alice")
        assert result is False

    def test_outer_exception_returns_false(self):
        with patch(
            "local_deep_research.web.routes.globals.set_termination_flag",
            side_effect=RuntimeError("boom"),
        ):
            from local_deep_research.web.services.research_service import (
                cancel_research,
            )

            result = cancel_research("r1", "alice")
        assert result is False


# ---------------------------------------------------------------------------
# run_research_process - the big one
# ---------------------------------------------------------------------------

# Common patches for run_research_process
_RUN_PATCHES = {
    "is_termination_requested": False,
    "is_research_active": True,
    "update_progress_and_check_active": lambda rid, prog: (prog, True),
}


def _make_research_mock(
    status="in_progress", research_meta=None, report_content=""
):
    r = MagicMock()
    r.status = status
    r.research_meta = research_meta or {}
    r.report_content = report_content
    r.created_at = datetime.now(UTC).isoformat()
    r.completed_at = None
    r.duration_seconds = None
    return r


class TestRunResearchProcessNoUsername:
    """run_research_process raises ValueError when username is missing."""

    def test_no_username_raises(self):
        raw_fn = _get_raw_run_research_process()
        with pytest.raises(ValueError, match="Username is required"):
            raw_fn("r1", "query", "quick")


class TestRunResearchProcessTerminatedBeforeStart:
    """Research terminated before starting."""

    @patch(f"{RS}.cleanup_research_resources")
    def test_terminated_early(self, mock_cleanup):
        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(0, True),
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn("r1", "query", "quick", username="alice")
        mock_cleanup.assert_called_once_with("r1", "alice", user_password=None)


class TestRunResearchProcessQuickMode:
    """Quick mode research process end-to-end."""

    def _run_quick(self, results, search_error=None, **extra_kwargs):
        """Helper to run quick mode with mocked dependencies."""
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research

        mock_system = MagicMock()
        if search_error:
            mock_system.analyze_topic.side_effect = search_error
        else:
            mock_system.analyze_topic.return_value = results

        mock_formatter = MagicMock()
        mock_formatter.format_document.return_value = "formatted content"

        mock_storage = MagicMock()
        mock_storage.save_report.return_value = True

        mock_sources_service = MagicMock()
        mock_sources_service.save_research_sources.return_value = 3

        mock_qp = MagicMock()

        kwargs = dict(
            username="alice",
            settings_snapshot={},
            **extra_kwargs,
        )

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.calculate_duration", return_value=10.0),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
            patch(
                f"{RS}.extract_links_from_search_results",
                return_value=[{"url": "http://example.com", "title": "Ex"}],
            ),
            patch(
                "local_deep_research.web.services.research_sources_service.ResearchSourcesService",
                return_value=mock_sources_service,
            ),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch(
                "local_deep_research.settings.logger.log_settings",
            ),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context",
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn("r1", "test query", "quick", **kwargs)

        return research, mock_storage, mock_system

    def test_quick_mode_success(self):
        results = {
            "findings": [
                {
                    "phase": "search",
                    "content": "found stuff",
                    "search_results": [{"url": "http://a.com"}],
                }
            ],
            "formatted_findings": "# Summary\nGreat results",
            "iterations": 3,
        }
        research, mock_storage, _ = self._run_quick(
            results, model="test-model", search_engine="google"
        )
        assert research.status == "completed"
        mock_storage.save_report.assert_called_once()

    def test_quick_mode_no_findings_queues_error(self):
        results = {
            "findings": [],
            "formatted_findings": None,
            "iterations": 0,
        }
        # No findings -> error is caught by outer handler, not raised
        # The function handles errors internally and queues error updates
        self._run_quick(results)

    def test_quick_mode_error_in_findings_token_limit(self):
        """Error in formatted_findings with token limit should trigger fallback."""
        results = {
            "findings": [
                {
                    "phase": "Final synthesis",
                    "content": "some synthesized content",
                }
            ],
            "formatted_findings": "Error: token limit exceeded context length",
            "iterations": 2,
        }
        research, _, _ = self._run_quick(results)
        assert research.status == "completed"

    def test_quick_mode_error_in_findings_timeout(self):
        results = {
            "findings": [],
            "formatted_findings": "Error: request timed out",
            "iterations": 1,
            "current_knowledge": "partial knowledge here",
        }
        research, _, _ = self._run_quick(results)
        assert research.status == "completed"

    def test_quick_mode_error_rate_limit(self):
        results = {
            "findings": [{"phase": "s1", "content": "data"}],
            "formatted_findings": "Error: rate limit reached",
            "iterations": 1,
        }
        research, _, _ = self._run_quick(results)
        assert research.status == "completed"

    def test_quick_mode_error_connection(self):
        results = {
            "findings": [{"phase": "s1", "content": "data"}],
            "formatted_findings": "Error: connection refused network error",
            "iterations": 1,
        }
        research, _, _ = self._run_quick(results)
        assert research.status == "completed"

    def test_quick_mode_error_llm_error(self):
        results = {
            "findings": [{"phase": "s1", "content": "data"}],
            "formatted_findings": "Error: llm error occurred",
            "iterations": 1,
        }
        research, _, _ = self._run_quick(results)
        assert research.status == "completed"

    def test_quick_mode_error_unknown(self):
        results = {
            "findings": [{"phase": "s1", "content": "data"}],
            "formatted_findings": "Error: something mysterious happened",
            "iterations": 1,
        }
        research, _, _ = self._run_quick(results)
        assert research.status == "completed"

    def test_quick_mode_error_no_valid_fallback_all_errors(self):
        """All findings have error content -> emergency fallback."""
        results = {
            "findings": [
                {"phase": "s1", "content": "Error: fail1"},
                {"phase": "s2", "content": "Error: fail2"},
            ],
            "formatted_findings": "Error: synthesis failed",
            "iterations": 1,
        }
        research, _, _ = self._run_quick(results)
        assert research.status == "completed"

    def test_quick_mode_error_valid_findings_combined(self):
        """Some findings are valid, some errors -> combined fallback."""
        results = {
            "findings": [
                {"phase": "s1", "content": "good data here"},
                {"phase": "s2", "content": "Error: bad data"},
            ],
            "formatted_findings": "Error: final answer synthesis fail",
            "iterations": 1,
        }
        research, _, _ = self._run_quick(results)
        assert research.status == "completed"

    def test_quick_mode_news_search_generates_headlines(self):
        """News search metadata should trigger headline/topic generation."""
        mock_session = MagicMock()
        research = _make_research_mock(
            research_meta={"is_news_search": True, "category": "Tech"}
        )
        research.report_content = "some report"
        mock_session.query.return_value.filter_by.return_value.first.return_value = research

        mock_system = MagicMock()
        results = {
            "findings": [{"phase": "search", "content": "news data"}],
            "formatted_findings": "# News Summary",
            "iterations": 2,
        }
        mock_system.analyze_topic.return_value = results

        mock_formatter = MagicMock()
        mock_formatter.format_document.return_value = "formatted"
        mock_storage = MagicMock()
        mock_storage.save_report.return_value = True
        mock_qp = MagicMock()

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.calculate_duration", return_value=10.0),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
            patch(
                f"{RS}.extract_links_from_search_results",
                return_value=[],
            ),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
            patch(
                "local_deep_research.news.utils.headline_generator.generate_headline",
                return_value="Breaking: Test",
            ),
            patch(
                "local_deep_research.news.utils.topic_generator.generate_topics",
                return_value=["topic1", "topic2"],
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "test",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )
        assert research.status == "completed"


class TestRunResearchProcessDetailedMode:
    """Detailed/full report mode."""

    def test_detailed_mode_success(self):
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research

        mock_system = MagicMock()
        mock_search_system = MagicMock()
        mock_search_system.all_links_of_system = [
            {"url": "http://a.com", "title": "A"}
        ]
        results = {
            "findings": [{"content": "data"}],
            "formatted_findings": "# Report",
            "iterations": 5,
            "search_system": mock_search_system,
        }
        mock_system.analyze_topic.return_value = results

        mock_formatter = MagicMock()
        mock_formatter.format_document.return_value = "formatted report"

        mock_report_gen = MagicMock()
        mock_report_gen.generate_report.return_value = {
            "content": "# Full Report",
            "metadata": {"sections": 3},
        }

        mock_sources_service = MagicMock()
        mock_sources_service.save_research_sources.return_value = 1
        mock_qp = MagicMock()

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.calculate_duration", return_value=20.0),
            patch(
                f"{RS}.IntegratedReportGenerator", return_value=mock_report_gen
            ),
            patch(
                f"{RS}.extract_links_from_search_results",
                return_value=[],
            ),
            patch(
                "local_deep_research.web.services.research_sources_service.ResearchSourcesService",
                return_value=mock_sources_service,
            ),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "query",
                "detailed",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )
        assert research.status == "completed"

    def test_detailed_mode_report_not_found_raises(self):
        mock_session = MagicMock()
        # First call returns research, second returns None (report not found)
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.side_effect = [
            research,  # first query in report save
            None,  # research not found
        ]

        mock_system = MagicMock()
        mock_search_system = MagicMock()
        mock_search_system.all_links_of_system = []
        results = {
            "findings": [{"content": "data"}],
            "formatted_findings": "# Report",
            "iterations": 2,
            "search_system": mock_search_system,
        }
        mock_system.analyze_topic.return_value = results

        mock_formatter = MagicMock()
        mock_formatter.format_document.return_value = "formatted"

        mock_report_gen = MagicMock()
        mock_report_gen.generate_report.return_value = {
            "content": "# Full Report",
            "metadata": {"sections": 1},
        }
        mock_qp = MagicMock()

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.calculate_duration", return_value=5.0),
            patch(
                f"{RS}.IntegratedReportGenerator", return_value=mock_report_gen
            ),
            patch(
                f"{RS}.extract_links_from_search_results",
                return_value=[],
            ),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            # This should handle the error gracefully (error handler catches it)
            raw_fn(
                "r1",
                "query",
                "detailed",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )
        # Error handler should queue error update
        mock_qp.queue_error_update.assert_called()


class TestRunResearchProcessSearchErrors:
    """Test search error handling in run_research_process."""

    def _run_with_search_error(self, error_msg):
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research

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

        mock_qp = MagicMock()
        mock_error_gen = MagicMock()
        mock_error_gen.generate_error_report.return_value = "# Error Report"
        mock_storage = MagicMock()
        mock_storage.save_report.return_value = True

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.ErrorReportGenerator", return_value=mock_error_gen),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )
        return mock_qp

    def test_503_error(self):
        qp = self._run_with_search_error("Something status code: 503 happened")
        qp.queue_error_update.assert_called_once()

    def test_404_error(self):
        qp = self._run_with_search_error("status code: 404 not found")
        qp.queue_error_update.assert_called_once()

    def test_other_status_code(self):
        qp = self._run_with_search_error("status code: 429 rate limited")
        qp.queue_error_update.assert_called_once()

    def test_connection_error(self):
        qp = self._run_with_search_error("Connection refused to host")
        qp.queue_error_update.assert_called_once()

    def test_generic_error(self):
        qp = self._run_with_search_error("something completely unknown")
        qp.queue_error_update.assert_called_once()


class TestRunResearchProcessLLMConfigErrors:
    """Test LLM/search configuration error handling."""

    def test_llm_config_error_llamacpp(self):
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research
        mock_qp = MagicMock()

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(
                f"{RS}.get_llm",
                side_effect=Exception("llamacpp model path is invalid"),
            ),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.ErrorReportGenerator"),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=MagicMock(),
            ),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                model_provider="llamacpp",
            )
        # Should queue error
        mock_qp.queue_error_update.assert_called_once()

    def test_search_engine_config_error(self):
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research
        mock_qp = MagicMock()

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(
                f"{RS}.get_search",
                side_effect=Exception("searxng instance_url not configured"),
            ),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.ErrorReportGenerator"),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=MagicMock(),
            ),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="searxng",
            )
        mock_qp.queue_error_update.assert_called_once()


class TestRunResearchProcessErrorHandler:
    """Test the error handler within run_research_process."""

    def test_error_with_ollama_unavailable(self):
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research
        mock_qp = MagicMock()

        mock_system = MagicMock()
        mock_system.analyze_topic.side_effect = Exception(
            "Ollama error (Error type: ollama_unavailable)"
        )

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.ErrorReportGenerator"),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=MagicMock(),
            ),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )

        call_kwargs = mock_qp.queue_error_update.call_args[1]
        assert "Ollama" in call_kwargs["error_message"]

    def test_error_with_model_not_found(self):
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research
        mock_qp = MagicMock()
        mock_system = MagicMock()
        mock_system.analyze_topic.side_effect = Exception(
            "Error type: model_not_found"
        )

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.ErrorReportGenerator"),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=MagicMock(),
            ),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )
        call_kwargs = mock_qp.queue_error_update.call_args[1]
        assert (
            "model not found" in call_kwargs["error_message"].lower()
            or "model" in call_kwargs["error_message"].lower()
        )

    def test_error_with_connection_error_type(self):
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research
        mock_qp = MagicMock()
        mock_system = MagicMock()
        mock_system.analyze_topic.side_effect = Exception(
            "Error type: connection_error"
        )

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.ErrorReportGenerator"),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=MagicMock(),
            ),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )
        call_kwargs = mock_qp.queue_error_update.call_args[1]
        assert (
            "connection" in call_kwargs["error_message"].lower()
            or "Connection" in call_kwargs["error_message"]
        )

    def test_error_with_api_error_type(self):
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research
        mock_qp = MagicMock()
        mock_system = MagicMock()
        mock_system.analyze_topic.side_effect = Exception(
            "Error type: api_error"
        )

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.ErrorReportGenerator"),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=MagicMock(),
            ),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )
        mock_qp.queue_error_update.assert_called_once()

    def test_error_no_username_cannot_queue(self):
        """Error handler when no username -> cannot queue error update."""
        # This tests the branch where username is None in the error handler.
        # We need to bypass the initial username check by having it set but then
        # making something else fail.
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research
        mock_qp = MagicMock()
        mock_system = MagicMock()
        mock_system.analyze_topic.side_effect = Exception("fail")

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.ErrorReportGenerator"),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=MagicMock(),
            ),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )
        # Should still queue with username
        mock_qp.queue_error_update.assert_called_once()

    def test_error_termination_requested_marks_suspended(self):
        """If termination was requested during error, status should be SUSPENDED."""
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research
        mock_qp = MagicMock()
        mock_system = MagicMock()
        mock_system.analyze_topic.side_effect = Exception("fail")

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.ErrorReportGenerator"),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=MagicMock(),
            ),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            # is_termination_requested returns True, so it exits early
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )


class TestRunResearchProcessResearchContext:
    """Test research context (follow-up research) handling."""

    def test_follow_up_research_context(self):
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research

        mock_system = MagicMock()
        results = {
            "findings": [{"phase": "search", "content": "data"}],
            "formatted_findings": "# Results",
            "iterations": 1,
        }
        mock_system.analyze_topic.return_value = results

        mock_formatter = MagicMock()
        mock_formatter.format_document.return_value = "formatted"
        mock_storage = MagicMock()
        mock_storage.save_report.return_value = True
        mock_qp = MagicMock()

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.calculate_duration", return_value=5.0),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
            patch(f"{RS}.extract_links_from_search_results", return_value=[]),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "follow-up query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
                research_context={
                    "past_findings": "previous research data" * 100
                },
            )
        assert research.status == "completed"


class TestRunResearchProcessSettingsContext:
    """Test SettingsContext inner class behavior."""

    def test_settings_context_with_setting_objects(self):
        """Settings snapshot with full setting objects (dict with 'value' key)."""
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research

        mock_system = MagicMock()
        results = {
            "findings": [{"phase": "search", "content": "data"}],
            "formatted_findings": "# Results",
            "iterations": 1,
        }
        mock_system.analyze_topic.return_value = results
        mock_formatter = MagicMock()
        mock_formatter.format_document.return_value = "formatted"
        mock_storage = MagicMock()
        mock_storage.save_report.return_value = True
        mock_qp = MagicMock()

        # Settings snapshot with both raw values and setting objects
        snapshot = {
            "report.citation_format": {
                "value": "number_hyperlinks",
                "type": "str",
            },
            "search.max_results": 10,  # raw value
        }

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.calculate_duration", return_value=5.0),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
            patch(f"{RS}.extract_links_from_search_results", return_value=[]),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ) as mock_set_ctx,
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot=snapshot,
                model="m",
                search_engine="s",
            )
        # Verify settings context was set
        mock_set_ctx.assert_called_once()
        ctx = mock_set_ctx.call_args[0][0]
        # The SettingsContext should extract values from setting objects
        assert ctx.get_setting("report.citation_format") == "number_hyperlinks"
        assert ctx.get_setting("search.max_results") == 10
        assert ctx.get_setting("nonexistent", "default") == "default"


class TestRunResearchProcessSubscription:
    """Test subscription update handling in quick mode."""

    def test_subscription_update_on_completion(self):
        mock_session = MagicMock()
        research = _make_research_mock(
            research_meta={"subscription_id": "sub_123"}
        )
        mock_session.query.return_value.filter_by.return_value.first.return_value = research

        mock_system = MagicMock()
        results = {
            "findings": [{"phase": "s", "content": "data"}],
            "formatted_findings": "# Results",
            "iterations": 1,
        }
        mock_system.analyze_topic.return_value = results
        mock_formatter = MagicMock()
        mock_formatter.format_document.return_value = "formatted"
        mock_storage = MagicMock()
        mock_storage.save_report.return_value = True
        mock_qp = MagicMock()

        mock_sub_storage = MagicMock()
        mock_sub_storage.get.return_value = {"refresh_minutes": 60}

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.calculate_duration", return_value=5.0),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
            patch(f"{RS}.extract_links_from_search_results", return_value=[]),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
            patch(
                "local_deep_research.news.subscription_manager.storage.SQLSubscriptionStorage",
                return_value=mock_sub_storage,
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )
        mock_sub_storage.update_refresh_time.assert_called_once()
        mock_sub_storage.increment_stats.assert_called_once_with("sub_123", 1)


class TestRunResearchProcessFinallyBlock:
    """Test the finally block resource cleanup."""

    def test_finally_closes_resources(self):
        """Verify use_search, system, use_llm get closed in finally."""
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research

        mock_llm = MagicMock()
        mock_search = MagicMock()
        mock_system = MagicMock()
        results = {
            "findings": [{"phase": "s", "content": "data"}],
            "formatted_findings": "# Results",
            "iterations": 1,
        }
        mock_system.analyze_topic.return_value = results
        mock_formatter = MagicMock()
        mock_formatter.format_document.return_value = "formatted"
        mock_storage = MagicMock()
        mock_storage.save_report.return_value = True
        mock_qp = MagicMock()

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=mock_llm),
            patch(f"{RS}.get_search", return_value=mock_search),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.calculate_duration", return_value=5.0),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
            patch(f"{RS}.extract_links_from_search_results", return_value=[]),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )
        mock_search.close.assert_called_once()
        mock_system.close.assert_called_once()
        mock_llm.close.assert_called_once()

    def test_finally_close_exceptions_suppressed(self):
        """Exceptions in close() calls should be suppressed."""
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research

        mock_llm = MagicMock()
        mock_llm.close.side_effect = RuntimeError("close fail")
        mock_search = MagicMock()
        mock_search.close.side_effect = RuntimeError("close fail")
        mock_system = MagicMock()
        mock_system.close.side_effect = RuntimeError("close fail")
        results = {
            "findings": [{"phase": "s", "content": "data"}],
            "formatted_findings": "# Results",
            "iterations": 1,
        }
        mock_system.analyze_topic.return_value = results
        mock_formatter = MagicMock()
        mock_formatter.format_document.return_value = "formatted"
        mock_storage = MagicMock()
        mock_storage.save_report.return_value = True
        mock_qp = MagicMock()

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=mock_llm),
            patch(f"{RS}.get_search", return_value=mock_search),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.calculate_duration", return_value=5.0),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
            patch(f"{RS}.extract_links_from_search_results", return_value=[]),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            # Should not raise despite close errors
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )


class TestRunResearchProcessSaveReportFailure:
    """Test report save failure in quick mode."""

    def test_save_report_failure_raises(self):
        mock_session = MagicMock()
        research = _make_research_mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = research

        mock_system = MagicMock()
        results = {
            "findings": [{"phase": "s", "content": "data"}],
            "formatted_findings": "# Results",
            "iterations": 1,
        }
        mock_system.analyze_topic.return_value = results
        mock_formatter = MagicMock()
        mock_formatter.format_document.return_value = "formatted"
        mock_storage = MagicMock()
        mock_storage.save_report.return_value = False  # Save fails
        mock_qp = MagicMock()

        with (
            patch(
                "local_deep_research.web.routes.globals.is_termination_requested",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.routes.globals.is_research_active",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.routes.globals.update_progress_and_check_active",
                return_value=(50, True),
            ),
            patch(f"{RS}.get_llm", return_value=MagicMock()),
            patch(f"{RS}.get_search", return_value=MagicMock()),
            patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
            patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
            patch(
                f"{RS}.get_user_db_session",
                side_effect=_fake_session_ctx(mock_session),
            ),
            patch(f"{RS}.cleanup_research_resources"),
            patch(f"{RS}.SocketIOService"),
            patch(f"{RS}.set_search_context"),
            patch(f"{RS}.calculate_duration", return_value=5.0),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
            patch(f"{RS}.extract_links_from_search_results", return_value=[]),
            patch(
                "local_deep_research.web.queue.processor_v2.queue_processor",
                mock_qp,
            ),
            patch("local_deep_research.settings.logger.log_settings"),
            patch(
                "local_deep_research.config.thread_settings.set_settings_context"
            ),
        ):
            raw_fn = _get_raw_run_research_process()
            # The error handler should catch this
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={},
                model="m",
                search_engine="s",
            )
        # Should queue error
        mock_qp.queue_error_update.assert_called_once()
