"""Extra coverage tests for research_service.py targeting cancel/termination/cleanup.

Targets functions NOT covered by test_research_service_deep_coverage.py:
- cancel_research: active, not found, terminal state, non-active non-terminal, db exception, top-level
- handle_termination: success, queue processor exception
- cleanup_research_resources: completed status, no username, socket emit exception
- _generate_report_path: normal query, unicode/special chars, different queries produce different paths
- _parse_research_metadata: dict, JSON string, invalid JSON, non-string
- get_citation_formatter: various citation modes
- export_report_to_memory: success, unsupported format
- save_research_strategy: create new, update existing, exception
- get_research_strategy: found, not found, exception
- start_research_process: starts thread
"""

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"
GLOBALS_MOD = "local_deep_research.web.routes.globals"
QUEUE_PROC_MOD = "local_deep_research.web.queue.processor_v2"
ENV_REGISTRY_MOD = "local_deep_research.settings.env_registry"


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


def _fake_session_ctx(session=None):
    if session is None:
        session = MagicMock()

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

    return ctx


def _make_mock_research(status=None):
    r = MagicMock()
    r.status = status
    return r


# ---------------------------------------------------------------------------
# cancel_research
# ---------------------------------------------------------------------------


class TestCancelResearch:
    def test_active_research_terminates(self):
        """Active research → sets termination flag, calls handle_termination, returns True."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

        with (
            patch(f"{GLOBALS_MOD}.set_termination_flag") as mock_flag,
            patch(f"{GLOBALS_MOD}.is_research_active", return_value=True),
            patch(f"{MODULE}.handle_termination") as mock_term,
        ):
            result = cancel_research("res-1", "testuser")

        assert result is True
        mock_flag.assert_called_once_with("res-1")
        mock_term.assert_called_once_with("res-1", "testuser")

    def test_not_found_returns_false(self):
        """Research not in active dict and not in DB → returns False."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

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

        with (
            patch(f"{GLOBALS_MOD}.set_termination_flag"),
            patch(f"{GLOBALS_MOD}.is_research_active", return_value=False),
            patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)),
        ):
            result = cancel_research("no-such", "testuser")

        assert result is False

    def test_terminal_state_returns_true(self):
        """Research already completed → returns True (already stopped)."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

        research = _make_mock_research(status="completed")
        ms = MagicMock()
        ms.query.return_value.filter_by.return_value.first.return_value = (
            research
        )

        with (
            patch(f"{GLOBALS_MOD}.set_termination_flag"),
            patch(f"{GLOBALS_MOD}.is_research_active", return_value=False),
            patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)),
        ):
            result = cancel_research("res-1", "testuser")

        assert result is True

    def test_non_active_non_terminal_suspends(self):
        """Research in DB but not active/terminal → suspended, returns True."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

        research = _make_mock_research(status="in_progress")
        ms = MagicMock()
        ms.query.return_value.filter_by.return_value.first.return_value = (
            research
        )

        with (
            patch(f"{GLOBALS_MOD}.set_termination_flag"),
            patch(f"{GLOBALS_MOD}.is_research_active", return_value=False),
            patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)),
        ):
            result = cancel_research("res-1", "testuser")

        assert result is True
        assert research.status == "suspended"
        ms.commit.assert_called_once()

    def test_db_exception_returns_false(self):
        """DB error during non-active lookup → returns False."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

        with (
            patch(f"{GLOBALS_MOD}.set_termination_flag"),
            patch(f"{GLOBALS_MOD}.is_research_active", return_value=False),
            patch(
                f"{MODULE}.get_user_db_session",
                side_effect=Exception("db error"),
            ),
        ):
            result = cancel_research("res-1", "testuser")

        assert result is False

    def test_unexpected_top_level_exception(self):
        """Top-level exception → returns False."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

        with patch(
            f"{GLOBALS_MOD}.set_termination_flag",
            side_effect=RuntimeError("boom"),
        ):
            result = cancel_research("res-1", "testuser")

        assert result is False


# ---------------------------------------------------------------------------
# handle_termination
# ---------------------------------------------------------------------------


class TestHandleTermination:
    def test_success_queues_error_update(self):
        """Queues suspension update and calls cleanup."""
        from local_deep_research.web.services.research_service import (
            handle_termination,
        )

        mock_qp = MagicMock()

        with (
            patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp),
            patch(f"{MODULE}.cleanup_research_resources") as mock_cleanup,
        ):
            handle_termination("res-1", "testuser")

        mock_qp.queue_error_update.assert_called_once()
        call_kwargs = mock_qp.queue_error_update.call_args
        assert call_kwargs.kwargs["research_id"] == "res-1"
        assert call_kwargs.kwargs["status"] == "suspended"
        # Cleanup must be told this ended via termination so the final
        # socket message reports SUSPENDED, not a spurious "completed".
        mock_cleanup.assert_called_once_with(
            "res-1", "testuser", final_status="suspended"
        )

    def test_queue_processor_exception_still_cleans_up(self):
        """Queue processor error → swallowed, cleanup still runs."""
        from local_deep_research.web.services.research_service import (
            handle_termination,
        )

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

        with (
            patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp),
            patch(f"{MODULE}.cleanup_research_resources") as mock_cleanup,
        ):
            handle_termination("res-1", "testuser")

        mock_cleanup.assert_called_once_with(
            "res-1", "testuser", final_status="suspended"
        )


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


class TestCleanupResearchResources:
    def _run_cleanup(self, mock_qp=None, mock_sio=None):
        from local_deep_research.web.services.research_service import (
            cleanup_research_resources,
        )

        if mock_qp is None:
            mock_qp = MagicMock()
        if mock_sio is None:
            mock_sio = MagicMock()

        with (
            patch(f"{GLOBALS_MOD}.cleanup_research"),
            patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp),
            patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False),
            patch(f"{MODULE}.SocketIOService", return_value=mock_sio),
        ):
            cleanup_research_resources("res-1", "testuser")

        return mock_qp, mock_sio

    def test_completed_status_emits_completion(self):
        """Default path emits completed final message."""
        mock_qp, mock_sio = self._run_cleanup()

        mock_qp.notify_research_completed.assert_called_once_with(
            "testuser", "res-1", user_password=None
        )
        mock_sio.emit_to_subscribers.assert_called_once()
        call_args = mock_sio.emit_to_subscribers.call_args
        event_data = call_args[0][2]
        assert event_data["progress"] == 100

    def test_suspended_final_status_does_not_emit_completed(self):
        """Regression: on the termination path the caller passes
        final_status=SUSPENDED, so the final socket message must report
        SUSPENDED (progress 0) — NOT a hard-coded 'completed' (progress
        100). The previous hard-coded COMPLETED made the chat client render
        an answer over a stopped state and flipped the standard progress
        page to 100%/Completed on user stop."""
        from local_deep_research.constants import ResearchStatus
        from local_deep_research.web.services.research_service import (
            cleanup_research_resources,
        )

        mock_qp = MagicMock()
        mock_sio = MagicMock()

        with (
            patch(f"{GLOBALS_MOD}.cleanup_research"),
            patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp),
            patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False),
            patch(f"{MODULE}.SocketIOService", return_value=mock_sio),
        ):
            cleanup_research_resources(
                "res-1",
                "testuser",
                final_status=ResearchStatus.SUSPENDED,
            )

        mock_sio.emit_to_subscribers.assert_called_once()
        event_data = mock_sio.emit_to_subscribers.call_args[0][2]
        assert event_data["status"] == ResearchStatus.SUSPENDED
        assert event_data["status"] != ResearchStatus.COMPLETED
        # Suspended research shows 0%, not a misleading 100%.
        assert event_data["progress"] == 0

    def test_no_username_skips_notify(self):
        """No username → skips queue processor notify."""
        from local_deep_research.web.services.research_service import (
            cleanup_research_resources,
        )

        mock_qp = MagicMock()

        with (
            patch(f"{GLOBALS_MOD}.cleanup_research"),
            patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp),
            patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False),
            patch(f"{MODULE}.SocketIOService", return_value=MagicMock()),
        ):
            cleanup_research_resources("res-1", username=None)

        mock_qp.notify_research_completed.assert_not_called()

    def test_socket_emit_exception_swallowed(self):
        """Socket emit error → swallowed gracefully."""
        from local_deep_research.web.services.research_service import (
            cleanup_research_resources,
        )

        mock_sio = MagicMock()
        mock_sio.emit_to_subscribers.side_effect = RuntimeError("socket down")

        with (
            patch(f"{GLOBALS_MOD}.cleanup_research"),
            patch(f"{QUEUE_PROC_MOD}.queue_processor", MagicMock()),
            patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False),
            patch(f"{MODULE}.SocketIOService", return_value=mock_sio),
        ):
            # Should not raise
            cleanup_research_resources("res-1", "testuser")


# ---------------------------------------------------------------------------
# _generate_report_path
# ---------------------------------------------------------------------------


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

        path = _generate_report_path("What is machine learning?")
        assert path.suffix == ".md"
        assert "research_report_" in path.name

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

        path = _generate_report_path("日本語クエリ $pecial Ch@rs!")
        assert path.suffix == ".md"
        assert "research_report_" in path.name

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

        path1 = _generate_report_path("query one")
        path2 = _generate_report_path("query two")
        # Hash portion should differ
        assert path1.name != path2.name


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


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

        result = _parse_research_metadata({"key": "val"})
        assert result == {"key": "val"}

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

        result = _parse_research_metadata('{"key": "val"}')
        assert result == {"key": "val"}

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

        result = _parse_research_metadata("not json")
        assert result == {}

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

        result = _parse_research_metadata(None)
        assert result == {}

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

        result = _parse_research_metadata(42)
        assert result == {}


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


class TestGetCitationFormatter:
    def test_default_mode(self):
        from local_deep_research.web.services.research_service import (
            get_citation_formatter,
        )

        with patch(
            "local_deep_research.config.search_config.get_setting_from_snapshot",
            return_value="number_hyperlinks",
        ):
            formatter = get_citation_formatter()

        assert formatter is not None

    def test_domain_hyperlinks_mode(self):
        from local_deep_research.web.services.research_service import (
            get_citation_formatter,
        )

        with patch(
            "local_deep_research.config.search_config.get_setting_from_snapshot",
            return_value="domain_hyperlinks",
        ):
            formatter = get_citation_formatter()

        assert formatter is not None

    def test_no_hyperlinks_mode(self):
        from local_deep_research.web.services.research_service import (
            get_citation_formatter,
        )

        with patch(
            "local_deep_research.config.search_config.get_setting_from_snapshot",
            return_value="no_hyperlinks",
        ):
            formatter = get_citation_formatter()

        assert formatter is not None

    def test_unknown_mode_falls_back(self):
        from local_deep_research.web.services.research_service import (
            get_citation_formatter,
        )

        with patch(
            "local_deep_research.config.search_config.get_setting_from_snapshot",
            return_value="nonexistent_mode",
        ):
            formatter = get_citation_formatter()

        assert formatter is not None


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


class TestExportReportToMemory:
    def test_success(self):
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        mock_exporter = MagicMock()
        mock_result = MagicMock()
        mock_result.content = b"PDF bytes"
        mock_result.filename = "report.pdf"
        mock_result.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
        ):
            content, filename, mimetype = export_report_to_memory(
                "# Report", "pdf", title="Test"
            )

        assert content == b"PDF bytes"
        assert filename == "report.pdf"

    def test_unsupported_format_raises(self):
        """Unsupported format raises ValueError."""
        from local_deep_research.exporters import ExporterRegistry

        original = ExporterRegistry.get_exporter

        try:
            ExporterRegistry.get_exporter = staticmethod(lambda fmt: None)
            ExporterRegistry.get_available_formats = staticmethod(
                lambda: ["pdf", "latex"]
            )

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

            with pytest.raises(ValueError, match="Unsupported"):
                export_report_to_memory("# Report", "docx")
        finally:
            ExporterRegistry.get_exporter = original


# ---------------------------------------------------------------------------
# save_research_strategy / get_research_strategy
# ---------------------------------------------------------------------------


class TestSaveResearchStrategy:
    def test_create_new_strategy(self):
        from local_deep_research.web.services.research_service import (
            save_research_strategy,
        )

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

        with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)):
            save_research_strategy("res-1", "source-based", username="user")

        ms.add.assert_called_once()
        ms.commit.assert_called_once()

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

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

        with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)):
            save_research_strategy("res-1", "langgraph-agent", username="user")

        assert existing.strategy_name == "langgraph-agent"
        ms.commit.assert_called_once()

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

        with patch(
            f"{MODULE}.get_user_db_session",
            side_effect=Exception("db fail"),
        ):
            # Should not raise
            save_research_strategy("res-1", "source-based", username="user")


class TestGetResearchStrategy:
    def test_found(self):
        from local_deep_research.web.services.research_service import (
            get_research_strategy,
        )

        strategy = MagicMock()
        strategy.strategy_name = "source-based"
        ms = MagicMock()
        ms.query.return_value.filter_by.return_value.first.return_value = (
            strategy
        )

        with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)):
            result = get_research_strategy("res-1", username="user")

        assert result == "source-based"

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

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

        with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)):
            result = get_research_strategy("res-1", username="user")

        assert result is None

    def test_exception_returns_none(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 fail"),
        ):
            result = get_research_strategy("res-1", username="user")

        assert result is None


# ---------------------------------------------------------------------------
# start_research_process
# ---------------------------------------------------------------------------


class TestStartResearchProcess:
    def test_starts_thread(self):
        from local_deep_research.web.services.research_service import (
            start_research_process,
        )

        callback = MagicMock()

        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,
            ),
            patch(f"{MODULE}.thread_context", return_value={}),
            patch(
                "local_deep_research.web.routes.globals.check_and_start_research",
                side_effect=fake_check_and_start,
            ),
            patch("threading.Thread") as mock_thread_cls,
        ):
            mock_thread = MagicMock()
            mock_thread.ident = 12345
            mock_thread_cls.return_value = mock_thread

            result = start_research_process(
                "res-1", "test query", "quick", callback, username="user"
            )

        mock_thread.start.assert_called_once()
        assert result is mock_thread
