"""
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", username="u")  # 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, username="u") 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.check_and_start_research",
            return_value=True,
        ) as mock_check_start:
            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_check_start.assert_called_once()
            args = mock_check_start.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", final_status="suspended"
        )

    @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")
        # Terminated before start → reports SUSPENDED, not "completed".
        mock_cleanup.assert_called_once_with(
            "r1", "alice", user_password=None, final_status="suspended"
        )


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

    def _run_quick(
        self, results, search_error=None, research_meta=None, **extra_kwargs
    ):
        """Helper to run quick mode with mocked dependencies."""
        mock_session = MagicMock()
        research = _make_research_mock(research_meta=research_meta)
        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={"search.tool": "searxng"},
            **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_failed_subscription_run_is_reset_to_due(self):
        """A FAILED subscription-triggered run resets next_refresh to due.

        Integration guard for the headline fix #1: the failure handler in
        run_research_process must call mark_subscription_due_by_id so a failed
        run is retried by the scheduler instead of being hidden a full interval.
        """
        with patch(
            "local_deep_research.news.subscription_runner.mark_subscription_due_by_id",
            return_value=True,
        ) as mock_reset:
            self._run_quick(
                results=None,
                search_error=RuntimeError("provider unavailable"),
                research_meta={
                    "subscription_id": "sub_x",
                    "is_news_search": True,
                },
            )

        mock_reset.assert_called_once()
        # called as mark_subscription_due_by_id(db_session, subscription_id)
        assert mock_reset.call_args.args[1] == "sub_x"

    def test_failed_non_subscription_run_does_not_reset(self):
        """A FAILED run that is NOT subscription-triggered never calls the
        reset (the guard must require a subscription_id)."""
        with patch(
            "local_deep_research.news.subscription_runner.mark_subscription_due_by_id",
            return_value=True,
        ) as mock_reset:
            self._run_quick(
                results=None,
                search_error=RuntimeError("boom"),
                research_meta={},  # no subscription_id
            )

        mock_reset.assert_not_called()

    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={"search.tool": "searxng"},
                model="m",
                search_engine="s",
            )
        assert research.status == "completed"


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

    def test_detailed_mode_success(self):
        """Detailed completion saves the report through the storage
        abstraction (get_report_storage), exactly like the quick path.

        Guards the H2 fix: the detailed path previously did a raw ORM write
        of report_content that bypassed get_report_storage and therefore the
        report.enable_file_backup feature. If that regresses, save_report
        stops being called and this fails.
        """
        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()
        # Exercise the normal citation path: format_document_split returns the
        # answer body plus non-empty sources, so apply_inline_hyperlinks is
        # skipped and the over-strip safety check is not triggered for this
        # short content. (Without this stub the test passes only via the
        # formatter's exception fallback, which is fragile.)
        mock_formatter.format_document_split.return_value = (
            "# Full Report",
            [{"url": "u"}],
        )

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

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

        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(
                "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.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={"search.tool": "searxng"},
                model="m",
                search_engine="s",
            )
        assert research.status == "completed"
        # Detailed routes report-save through the storage abstraction (the
        # H2 fix), passing the DETAILED report content.
        mock_storage.save_report.assert_called_once()
        assert (
            mock_storage.save_report.call_args.kwargs["content"]
            == "# Full Report"
        )

    def test_detailed_mode_writes_file_backup_when_enabled(self):
        """Detailed completion writes the on-disk file backup when the
        report.enable_file_backup setting is on.

        The user-visible half of the H2 fix: because the detailed path now
        routes through get_report_storage -> DatabaseWithFileBackupStorage, a
        user who enabled file backup gets files for detailed research, not
        only quick research. On the old raw ORM write get_report_storage was
        never called for detailed, so FileReportStorage was never invoked and
        this would fail.
        """
        from local_deep_research.storage.database_with_file_backup import (
            DatabaseWithFileBackupStorage,
        )

        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_report_gen = MagicMock()
        mock_report_gen.generate_report.return_value = {
            "content": "# Full Report",
            "metadata": {"sections": 3},
        }

        # Exercise the normal citation path (see test_detailed_mode_success)
        # rather than the formatter's exception fallback.
        mock_formatter = MagicMock()
        mock_formatter.format_document_split.return_value = (
            "# Full Report",
            [{"url": "u"}],
        )

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

        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
            ),
            # The on-disk writer is mocked so the test touches no filesystem.
            patch(
                "local_deep_research.storage.database_with_file_backup.FileReportStorage",
                return_value=mock_file_storage,
            ),
            # Construct the real backup-aware storage lazily (at call time, so
            # the FileReportStorage patch above is active) with backup ENABLED.
            patch(
                "local_deep_research.storage.get_report_storage",
                side_effect=lambda *a, **kw: DatabaseWithFileBackupStorage(
                    session=mock_session, enable_file_storage=True
                ),
            ),
            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={"search.tool": "searxng"},
                model="m",
                search_engine="s",
            )

        # The file backup was written exactly once, with the detailed report
        # content (positional: research_id, content, metadata, username).
        mock_file_storage.save_report.assert_called_once()
        assert (
            mock_file_storage.save_report.call_args.args[1] == "# Full Report"
        )

    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={"search.tool": "searxng"},
                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={"search.tool": "searxng"},
                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={"search.tool": "searxng"},
                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={"search.tool": "searxng"},
                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={"search.tool": "searxng"},
                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={"search.tool": "searxng"},
                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={"search.tool": "searxng"},
                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={"search.tool": "searxng"},
                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={"search.tool": "searxng"},
                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={"search.tool": "searxng"},
                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={"search.tool": "searxng"},
                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.
        # Includes search.tool so the run proceeds past the egress build
        # (which fails closed on a snapshot with no primary) to the
        # SettingsContext extraction under test.
        snapshot = {
            "report.citation_format": {
                "value": "number_hyperlinks",
                "type": "str",
            },
            "search.max_results": 10,  # raw value
            "search.tool": "searxng",
        }

        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

        # On completion the service loads the NewsSubscription via ORM
        # (db_session.query(...).filter(...).first()) and advances its refresh
        # schedule. The research-row lookups above use filter_by, so the
        # subscription lookup (which uses filter) is an independent mock chain.
        mock_sub = MagicMock()
        mock_sub.id = "sub_123"
        mock_sub.refresh_interval_minutes = 60
        mock_session.query.return_value.filter.return_value.first.return_value = mock_sub

        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=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_runner.advance_refresh_schedule",
            ) as mock_advance,
        ):
            raw_fn = _get_raw_run_research_process()
            raw_fn(
                "r1",
                "query",
                "quick",
                username="alice",
                settings_snapshot={"search.tool": "searxng"},
                model="m",
                search_engine="s",
            )
        mock_advance.assert_called_once()
        # Called with the loaded subscription as the first positional arg.
        assert mock_advance.call_args.args[0] is mock_sub


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={"search.tool": "searxng"},
                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={"search.tool": "searxng"},
                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={"search.tool": "searxng"},
                model="m",
                search_engine="s",
            )
        # Should queue error
        mock_qp.queue_error_update.assert_called_once()
