"""
Deep coverage tests for research_service.py targeting ~97 missing statements.

Focuses on:
- _parse_research_metadata edge cases
- get_citation_formatter all modes
- export_report_to_memory error paths
- save_research_strategy create vs update paths
- get_research_strategy error path
- start_research_process rate-limited callback
- progress_callback branches (throttling, metadata fields, phase adjustments)
- run_research_process: LLM config errors, search engine config errors,
  quick mode error types in formatted_findings, detailed mode, error handler
- cleanup_research_resources and handle_termination
"""

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

import pytest
from loguru import logger

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

MODULE = "local_deep_research.web.services.research_service"

# Correct patch targets for symbols that are lazily imported inside functions
GLOBALS_MOD = "local_deep_research.web.routes.globals"
THREAD_SETTINGS_MOD = "local_deep_research.config.thread_settings"
SETTINGS_LOGGER_MOD = "local_deep_research.settings.logger"
QUEUE_PROC_MOD = "local_deep_research.web.queue.processor_v2"
ENV_REGISTRY_MOD = "local_deep_research.settings.env_registry"
RESOURCE_UTILS_MOD = "local_deep_research.utilities.resource_utils"
STORAGE_MOD = "local_deep_research.storage"
SOURCES_SERVICE_MOD = (
    "local_deep_research.web.services.research_sources_service"
)
# get_setting_from_snapshot is imported into search_config from thread_settings
SEARCH_CONFIG_MOD = "local_deep_research.config.search_config"


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


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

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

    return ctx


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


def _base_run_patches(mock_session=None):
    """
    Return a dict of patches needed for run_research_process tests.

    The key challenge: run_research_process uses lazy imports inside the function body.
    These must be patched at their actual source module, not at MODULE.xyz.
    """
    if mock_session is None:
        mock_session = MagicMock()
        mock_research = _make_mock_research(research_meta={})
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

    return {
        # Module-level imports (can be patched at MODULE)
        f"{MODULE}.get_user_db_session": _fake_session_ctx(mock_session),
        f"{MODULE}.handle_termination": MagicMock(),
        f"{MODULE}.cleanup_research_resources": MagicMock(),
        f"{MODULE}.set_search_context": MagicMock(),
        f"{MODULE}.SocketIOService": MagicMock(),
        f"{MODULE}.calculate_duration": MagicMock(return_value=5),
        # Lazy imports inside function: patch at their actual source module
        f"{GLOBALS_MOD}.is_termination_requested": MagicMock(
            return_value=False
        ),
        f"{GLOBALS_MOD}.is_research_active": MagicMock(return_value=False),
        f"{GLOBALS_MOD}.update_progress_and_check_active": MagicMock(
            return_value=(5, True)
        ),
        f"{SETTINGS_LOGGER_MOD}.log_settings": MagicMock(),
        f"{THREAD_SETTINGS_MOD}.set_settings_context": MagicMock(),
    }


# ---------------------------------------------------------------------------
# _parse_research_metadata
# ---------------------------------------------------------------------------


class TestParseResearchMetadataDeep:
    def _call(self, value):
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        return _parse_research_metadata(value)

    def test_dict_returns_copy(self):
        original = {"a": 1}
        result = self._call(original)
        assert result == {"a": 1}
        assert result is not original

    def test_valid_json_string(self):
        result = self._call('{"key": "value"}')
        assert result == {"key": "value"}

    def test_invalid_json_string_returns_empty(self):
        result = self._call("{not valid json!!")
        assert result == {}

    def test_none_returns_empty(self):
        result = self._call(None)
        assert result == {}

    def test_integer_returns_empty(self):
        result = self._call(42)
        assert result == {}

    def test_empty_string_returns_empty(self):
        result = self._call("")
        assert result == {}


# ---------------------------------------------------------------------------
# get_citation_formatter
# ---------------------------------------------------------------------------


class TestGetCitationFormatterDeep:
    """
    get_citation_formatter lazily imports get_setting_from_snapshot from
    local_deep_research.config.search_config, which re-exports it from
    local_deep_research.config.thread_settings.
    Patch at the search_config module where it is looked up.
    """

    def test_domain_hyperlinks_mode(self):
        from local_deep_research.text_optimization import CitationMode

        with patch(
            f"{SEARCH_CONFIG_MOD}.get_setting_from_snapshot",
            return_value="domain_hyperlinks",
        ):
            from local_deep_research.web.services.research_service import (
                get_citation_formatter,
            )

            formatter = get_citation_formatter()
            assert formatter.mode == CitationMode.DOMAIN_HYPERLINKS

    def test_no_hyperlinks_mode(self):
        from local_deep_research.text_optimization import CitationMode

        with patch(
            f"{SEARCH_CONFIG_MOD}.get_setting_from_snapshot",
            return_value="no_hyperlinks",
        ):
            from local_deep_research.web.services.research_service import (
                get_citation_formatter,
            )

            formatter = get_citation_formatter()
            assert formatter.mode == CitationMode.NO_HYPERLINKS

    def test_unknown_mode_defaults_to_number_hyperlinks(self):
        from local_deep_research.text_optimization import CitationMode

        with patch(
            f"{SEARCH_CONFIG_MOD}.get_setting_from_snapshot",
            return_value="nonexistent_mode",
        ):
            from local_deep_research.web.services.research_service import (
                get_citation_formatter,
            )

            formatter = get_citation_formatter()
            assert formatter.mode == CitationMode.NUMBER_HYPERLINKS

    def test_domain_id_hyperlinks_mode(self):
        from local_deep_research.text_optimization import CitationMode

        with patch(
            f"{SEARCH_CONFIG_MOD}.get_setting_from_snapshot",
            return_value="domain_id_hyperlinks",
        ):
            from local_deep_research.web.services.research_service import (
                get_citation_formatter,
            )

            formatter = get_citation_formatter()
            assert formatter.mode == CitationMode.DOMAIN_ID_HYPERLINKS

    def test_domain_id_always_hyperlinks_mode(self):
        from local_deep_research.text_optimization import CitationMode

        with patch(
            f"{SEARCH_CONFIG_MOD}.get_setting_from_snapshot",
            return_value="domain_id_always_hyperlinks",
        ):
            from local_deep_research.web.services.research_service import (
                get_citation_formatter,
            )

            formatter = get_citation_formatter()
            assert formatter.mode == CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS


# ---------------------------------------------------------------------------
# export_report_to_memory
# ---------------------------------------------------------------------------


class TestExportReportToMemoryDeep:
    """
    export_report_to_memory lazily imports ExporterRegistry and ExportOptions
    from local_deep_research.exporters.
    Patch them at their actual module locations.
    """

    def test_unsupported_format_raises_value_error(self):
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        mock_registry = MagicMock()
        mock_registry.get_exporter.return_value = None
        mock_registry.get_available_formats.return_value = ["pdf", "odt"]

        with patch(
            "local_deep_research.exporters.registry.ExporterRegistry",
            mock_registry,
        ):
            with pytest.raises((ValueError, AttributeError)):
                export_report_to_memory("# content", "xyz", title="T")

    def test_successful_export_returns_tuple(self):
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        mock_result = MagicMock()
        mock_result.content = b"data"
        mock_result.filename = "report.pdf"
        mock_result.mimetype = "application/pdf"

        mock_exporter = MagicMock()
        mock_exporter.export.return_value = mock_result

        # Patch the ExporterRegistry where it is imported in the exporters package
        with patch(
            "local_deep_research.exporters.ExporterRegistry"
        ) as mock_registry_cls:
            mock_registry_cls.get_exporter.return_value = mock_exporter
            with patch("local_deep_research.exporters.ExportOptions"):
                content, filename, mimetype = export_report_to_memory(
                    "# hello", "PDF", title="My Title"
                )

        assert content == b"data"
        assert filename == "report.pdf"
        assert mimetype == "application/pdf"


# ---------------------------------------------------------------------------
# save_research_strategy
# ---------------------------------------------------------------------------


class TestSaveResearchStrategyDeep:
    def test_creates_new_strategy_when_none_exists(self):
        from local_deep_research.web.services.research_service import (
            save_research_strategy,
        )

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

        with patch(
            f"{MODULE}.get_user_db_session", _fake_session_ctx(mock_session)
        ):
            with patch(f"{MODULE}.ResearchStrategy") as mock_strategy_cls:
                save_research_strategy(42, "source-based", username="user1")

        mock_strategy_cls.assert_called_once_with(
            research_id=42, strategy_name="source-based"
        )
        mock_session.add.assert_called_once()
        mock_session.commit.assert_called_once()

    def test_updates_existing_strategy(self):
        from local_deep_research.web.services.research_service import (
            save_research_strategy,
        )

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

        with patch(
            f"{MODULE}.get_user_db_session", _fake_session_ctx(mock_session)
        ):
            save_research_strategy(42, "react", username="user1")

        assert existing.strategy_name == "react"
        mock_session.commit.assert_called_once()

    def test_exception_is_swallowed(self):
        from local_deep_research.web.services.research_service import (
            save_research_strategy,
        )

        with patch(
            f"{MODULE}.get_user_db_session", side_effect=RuntimeError("db down")
        ):
            # Should not raise
            save_research_strategy(99, "strategy", username="user1")


# ---------------------------------------------------------------------------
# get_research_strategy
# ---------------------------------------------------------------------------


class TestGetResearchStrategyDeep:
    def test_returns_none_on_exception(self):
        from local_deep_research.web.services.research_service import (
            get_research_strategy,
        )

        with patch(
            f"{MODULE}.get_user_db_session", side_effect=Exception("db err")
        ):
            result = get_research_strategy(1, username="user1")

        assert result is None

    def test_returns_none_when_no_strategy_found(self):
        from local_deep_research.web.services.research_service import (
            get_research_strategy,
        )

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

        with patch(
            f"{MODULE}.get_user_db_session", _fake_session_ctx(mock_session)
        ):
            result = get_research_strategy(99, username="user1")

        assert result is None

    def test_returns_strategy_name(self):
        """``get_research_strategy`` returns the stored strategy name
        verbatim — including the removed ``mcp`` value (#4548). It is a
        display-only read that never feeds the factory, so a saved 'mcp'
        research keeps loading after the strategy is gone."""
        from local_deep_research.web.services.research_service import (
            get_research_strategy,
        )

        mock_strategy = MagicMock()
        mock_strategy.strategy_name = "mcp"
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_strategy

        with patch(
            f"{MODULE}.get_user_db_session", _fake_session_ctx(mock_session)
        ):
            result = get_research_strategy(7, username="user1")

        assert result == "mcp"


# ---------------------------------------------------------------------------
# start_research_process - rate-limited callback
# ---------------------------------------------------------------------------


class TestStartResearchProcessDeep:
    def test_rate_limited_callback_acquires_and_releases_semaphore(self):
        """The rate-limited wrapper must acquire and release the global semaphore."""
        from local_deep_research.web.services.research_service import (
            start_research_process,
        )

        real_sem = threading.Semaphore(5)

        def fake_callback(ctx, rid, q, mode, **kw):
            pass

        mock_thread = MagicMock()
        mock_thread_cls = MagicMock(return_value=mock_thread)

        # Dedup stub: starts the thread and returns True.
        def fake_check_and_start(rid, data):
            data["thread"].start()
            return True

        with patch(
            f"{MODULE}.thread_with_app_context", side_effect=lambda f: f
        ):
            with patch(f"{MODULE}.threading.Thread", mock_thread_cls):
                with patch(f"{MODULE}.thread_context", return_value={}):
                    with patch(
                        f"{MODULE}._global_research_semaphore", real_sem
                    ):
                        with patch(
                            f"{GLOBALS_MOD}.check_and_start_research",
                            side_effect=fake_check_and_start,
                        ):
                            start_research_process(
                                research_id=1,
                                query="test",
                                mode="quick",
                                run_research_callback=fake_callback,
                            )

        # Thread was started (by the dedup helper stub)
        mock_thread.start.assert_called_once()

    def test_check_and_start_research_called_with_correct_keys(self):
        from local_deep_research.web.services.research_service import (
            start_research_process,
        )
        from local_deep_research.constants import ResearchStatus

        captured = []

        def capture(rid, data):
            captured.append((rid, data))
            return True

        with patch(
            f"{MODULE}.thread_with_app_context", side_effect=lambda f: f
        ):
            with patch(f"{MODULE}.threading.Thread") as mock_thread_cls:
                mock_thread_cls.return_value.start = MagicMock()
                with patch(f"{MODULE}.thread_context", return_value={}):
                    with patch(
                        f"{GLOBALS_MOD}.check_and_start_research",
                        side_effect=capture,
                    ):
                        start_research_process(
                            research_id=123,
                            query="q",
                            mode="quick",
                            run_research_callback=MagicMock(),
                        )

        assert len(captured) == 1
        rid, data = captured[0]
        assert rid == 123
        assert data["status"] == ResearchStatus.IN_PROGRESS
        assert data["progress"] == 0
        assert "thread" in data


# ---------------------------------------------------------------------------
# LLM config error paths (internal handling)
# ---------------------------------------------------------------------------


class TestLLMConfigErrorPaths:
    """
    run_research_process handles LLM config errors internally via the except handler.
    The ValueError raised for config errors is caught by the outer exception handler
    and processed via queue_processor.queue_error_update. The function does NOT
    re-raise the exception to the caller.
    """

    def _run_with_llm_error(
        self, error_message, model="gpt-4", model_provider="openai"
    ):
        """Run research with a failing get_llm and return the queue_error_update mock."""
        mock_session = MagicMock()
        mock_research = _make_mock_research(research_meta={})
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
        mock_qp = MagicMock()
        from local_deep_research.web.services.research_service import (
            run_research_process,
        )

        func = run_research_process.__wrapped__.__wrapped__
        with patch(
            f"{MODULE}.get_user_db_session", _fake_session_ctx(mock_session)
        ):
            with patch(f"{MODULE}.handle_termination"):
                with patch(f"{MODULE}.cleanup_research_resources"):
                    with patch(f"{MODULE}.set_search_context"):
                        with patch(f"{MODULE}.SocketIOService"):
                            with patch(
                                f"{MODULE}.ErrorReportGenerator",
                                return_value=MagicMock(
                                    generate_error_report=MagicMock(
                                        return_value="error report"
                                    )
                                ),
                            ):
                                with patch(
                                    f"{MODULE}.get_llm",
                                    side_effect=Exception(error_message),
                                ):
                                    with patch(
                                        f"{GLOBALS_MOD}.is_termination_requested",
                                        return_value=False,
                                    ):
                                        with patch(
                                            f"{GLOBALS_MOD}.is_research_active",
                                            return_value=False,
                                        ):
                                            with patch(
                                                f"{GLOBALS_MOD}.update_progress_and_check_active",
                                                return_value=(5, True),
                                            ):
                                                with patch(
                                                    f"{SETTINGS_LOGGER_MOD}.log_settings"
                                                ):
                                                    with patch(
                                                        f"{THREAD_SETTINGS_MOD}.set_settings_context"
                                                    ):
                                                        with patch(
                                                            f"{QUEUE_PROC_MOD}.queue_processor",
                                                            mock_qp,
                                                        ):
                                                            # Should NOT raise - errors are handled internally
                                                            func(
                                                                1,
                                                                "query",
                                                                "quick",
                                                                username="user1",
                                                                model=model,
                                                                model_provider=model_provider,
                                                                # Realistic primary so the run reaches the LLM-error
                                                                # path under test (not the egress fail-closed raise).
                                                                settings_snapshot={
                                                                    "search.tool": "searxng"
                                                                },
                                                            )
        return mock_qp

    def test_llamacpp_error_queues_error_update(self):
        mock_qp = self._run_with_llm_error("llamacpp model path not found")
        mock_qp.queue_error_update.assert_called_once()

    def test_model_path_error_queues_error_update(self):
        mock_qp = self._run_with_llm_error(
            "model path /nonexistent does not exist"
        )
        mock_qp.queue_error_update.assert_called_once()

    def test_gguf_error_queues_error_update(self):
        mock_qp = self._run_with_llm_error("requires a .gguf file")
        mock_qp.queue_error_update.assert_called_once()

    def test_generic_llm_error_queues_error_update(self):
        mock_qp = self._run_with_llm_error("some other random error")
        mock_qp.queue_error_update.assert_called_once()


# ---------------------------------------------------------------------------
# cleanup_research_resources
# ---------------------------------------------------------------------------


class TestCleanupResearchResourcesDeep:
    def test_notify_called_with_username(self):
        from local_deep_research.web.services.research_service import (
            cleanup_research_resources,
        )

        mock_qp = MagicMock()

        with patch(f"{GLOBALS_MOD}.cleanup_research"):
            with patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp):
                with patch(
                    f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False
                ):
                    cleanup_research_resources(99, username="user1")

        mock_qp.notify_research_completed.assert_called_once_with(
            "user1", 99, user_password=None
        )

    def test_no_username_skips_notify(self):
        from local_deep_research.web.services.research_service import (
            cleanup_research_resources,
        )

        mock_qp = MagicMock()

        with patch(f"{GLOBALS_MOD}.cleanup_research"):
            with patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp):
                with patch(
                    f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False
                ):
                    cleanup_research_resources(99, username=None)

        mock_qp.notify_research_completed.assert_not_called()
