"""Tests for chat-mode research termination — partial-content persistence,
final socket emit, and idempotency under mid-stream interrupts.

These tests target the helper directly with mocked ChatService and
SocketIOService. End-to-end termination through the worker thread is
covered by the chat e2e suite.
"""

from unittest.mock import patch, MagicMock


SESSION_ID = "00000000-0000-0000-0000-000000000001"
RESEARCH_ID = "00000000-0000-0000-0000-000000000002"
USERNAME = "alice"


def _import_helper():
    from local_deep_research.web.services.research_service import (
        _save_partial_chat_message_on_terminate,
        _STOPPED_BEFORE_OUTPUT_MARKER,
        _STOPPED_FOOTER,
    )

    return (
        _save_partial_chat_message_on_terminate,
        _STOPPED_BEFORE_OUTPUT_MARKER,
        _STOPPED_FOOTER,
    )


class TestPartialPersistOnTerminate:
    """`_save_partial_chat_message_on_terminate` behaviour."""

    def test_persists_partial_with_stopped_footer(self):
        helper, _marker, footer = _import_helper()

        with (
            patch("local_deep_research.chat.service.ChatService") as mock_svc,
            patch(
                "local_deep_research.web.services.research_service.SocketIOService"
            ) as mock_sock,
        ):
            instance = MagicMock()
            mock_svc.return_value = instance

            helper(
                SESSION_ID,
                RESEARCH_ID,
                USERNAME,
                "partial answer text",
            )

            mock_svc.assert_called_once_with(USERNAME)
            instance.add_message.assert_called_once()
            kwargs = instance.add_message.call_args.kwargs
            assert kwargs["session_id"] == SESSION_ID
            assert kwargs["role"] == "assistant"
            assert kwargs["message_type"] == "response"
            assert kwargs["research_id"] == RESEARCH_ID
            assert kwargs["content"].startswith("partial answer text")
            assert footer.strip() in kwargs["content"]

            # Socket.IO final-chunk emit must fire so the bubble drops the
            # streaming class on the client.
            mock_sock.return_value.emit_to_subscribers.assert_called_once()
            event_args = (
                mock_sock.return_value.emit_to_subscribers.call_args.args
            )
            assert event_args[0] == "response_chunk"
            assert event_args[1] == RESEARCH_ID
            payload = event_args[2]
            assert payload["is_final"] is True

    def test_persists_marker_when_no_chunks_streamed(self):
        helper, marker, _footer = _import_helper()

        with (
            patch("local_deep_research.chat.service.ChatService") as mock_svc,
            patch(
                "local_deep_research.web.services.research_service.SocketIOService"
            ),
        ):
            instance = MagicMock()
            mock_svc.return_value = instance

            helper(SESSION_ID, RESEARCH_ID, USERNAME, "")

            assert instance.add_message.call_args.kwargs["content"] == marker

    def test_truncated_marker_appended_when_buffer_capped(self):
        helper, _marker, _footer = _import_helper()

        with (
            patch("local_deep_research.chat.service.ChatService") as mock_svc,
            patch(
                "local_deep_research.web.services.research_service.SocketIOService"
            ),
        ):
            instance = MagicMock()
            mock_svc.return_value = instance

            helper(
                SESSION_ID,
                RESEARCH_ID,
                USERNAME,
                "a long but truncated stream",
                truncated=True,
            )

            content = instance.add_message.call_args.kwargs["content"]
            assert "truncated" in content.lower()

    def test_skips_silently_when_no_chat_session_id(self):
        helper, _marker, _footer = _import_helper()

        with (
            patch("local_deep_research.chat.service.ChatService") as mock_svc,
            patch(
                "local_deep_research.web.services.research_service.SocketIOService"
            ) as mock_sock,
        ):
            helper(None, RESEARCH_ID, USERNAME, "anything")

            mock_svc.assert_not_called()
            mock_sock.return_value.emit_to_subscribers.assert_not_called()

    def test_idempotent_via_streaming_state_flag(self):
        """Two calls with the same streaming_state must persist exactly one
        chat row — guards against the in-callback path and the outer
        except handler both firing on a mid-stream interrupt."""
        helper, _marker, _footer = _import_helper()

        state = {"chunks": [], "_persisted": False}

        with (
            patch("local_deep_research.chat.service.ChatService") as mock_svc,
            patch(
                "local_deep_research.web.services.research_service.SocketIOService"
            ) as mock_sock,
        ):
            instance = MagicMock()
            mock_svc.return_value = instance

            helper(
                SESSION_ID,
                RESEARCH_ID,
                USERNAME,
                "first call",
                streaming_state=state,
            )
            helper(
                SESSION_ID,
                RESEARCH_ID,
                USERNAME,
                "second call",
                streaming_state=state,
            )

            assert state["_persisted"] is True
            assert instance.add_message.call_count == 1
            assert mock_sock.return_value.emit_to_subscribers.call_count == 1

    def test_swallows_chat_service_failures(self):
        """Persistence failures must NOT crash — termination cleanup is
        best-effort."""
        helper, _marker, _footer = _import_helper()

        with (
            patch("local_deep_research.chat.service.ChatService") as mock_svc,
            patch(
                "local_deep_research.web.services.research_service.SocketIOService"
            ),
        ):
            mock_svc.side_effect = RuntimeError("DB exploded")

            # Should not raise.
            helper(SESSION_ID, RESEARCH_ID, USERNAME, "anything")


class TestNoDuplicateRowOnLateTermination:
    """Corner case: termination flag flips between the success-path response
    write (``_save_chat_message_and_context``) and the trailing
    ``progress_callback("Research completed successfully", 100)``.

    The trailing callback's termination check would otherwise call
    ``_save_partial_chat_message_on_terminate`` and write a SECOND
    response row. The fix: ``_save_chat_message_and_context`` sets
    ``streaming_state["_persisted"] = True`` after writing the success
    row, and the helper short-circuits on that flag.
    """

    def test_late_terminate_does_not_write_duplicate_row(self):
        """Simulate the production sequence: success-path writes the row,
        then a late terminate fires. Only one row total."""
        from local_deep_research.web.services.research_service import (
            _save_chat_message_and_context,
            _save_partial_chat_message_on_terminate,
        )

        streaming_state = {
            "chunks_sent": 3,
            "chunks": ["abc", "def", "ghi"],
            "_bytes": 9,
            "_truncated": False,
        }

        with (
            patch("local_deep_research.chat.service.ChatService") as mock_svc,
            patch(
                "local_deep_research.chat.context.ChatContextManager"
            ) as mock_ctx,
            patch(
                "local_deep_research.web.services.research_service.SocketIOService"
            ),
        ):
            success_chat = MagicMock()
            mock_svc.return_value = success_chat
            mock_ctx.return_value.extract_context_updates.return_value = {}

            # 1) Success path runs first — writes the response row.
            sock = MagicMock()
            _save_chat_message_and_context(
                SESSION_ID,
                RESEARCH_ID,
                USERNAME,
                "the full answer",
                streaming_enabled=True,
                streaming_state=streaming_state,
                socket_service=sock,
            )

            # Sanity: success-path wrote exactly one row + flipped the flag.
            assert success_chat.add_message.call_count == 1
            assert streaming_state["_persisted"] is True

            # 2) Trailing progress_callback detects termination and calls
            #    the helper. The flag must short-circuit it.
            _save_partial_chat_message_on_terminate(
                SESSION_ID,
                RESEARCH_ID,
                USERNAME,
                "".join(streaming_state["chunks"]),
                streaming_state=streaming_state,
            )

            # Still exactly one add_message — no duplicate row.
            assert success_chat.add_message.call_count == 1

    def test_late_terminate_emits_no_duplicate_final_chunk(self):
        """The success path emits its own ``is_final`` chunk; the helper
        must not emit a second one when short-circuited."""
        from local_deep_research.web.services.research_service import (
            _save_chat_message_and_context,
            _save_partial_chat_message_on_terminate,
        )

        streaming_state = {
            "chunks_sent": 1,
            "chunks": ["x"],
            "_bytes": 1,
            "_truncated": False,
        }

        success_sock = MagicMock()

        with (
            patch("local_deep_research.chat.service.ChatService"),
            patch("local_deep_research.chat.context.ChatContextManager"),
            patch(
                "local_deep_research.web.services.research_service.SocketIOService"
            ) as mock_terminate_sock,
        ):
            _save_chat_message_and_context(
                SESSION_ID,
                RESEARCH_ID,
                USERNAME,
                "answer",
                streaming_enabled=True,
                streaming_state=streaming_state,
                socket_service=success_sock,
            )

            # Success path emitted its own final chunk.
            assert success_sock.emit_to_subscribers.call_count == 1

            _save_partial_chat_message_on_terminate(
                SESSION_ID,
                RESEARCH_ID,
                USERNAME,
                "x",
                streaming_state=streaming_state,
            )

            # The helper's SocketIOService factory must NOT have been used —
            # the short-circuit fired before any emit.
            mock_terminate_sock.return_value.emit_to_subscribers.assert_not_called()
