"""Unit tests for ChatService."""

import uuid
from unittest.mock import MagicMock, patch

import pytest
from freezegun import freeze_time

from tests.chat.conftest import setup_query_mock_with_session


class TestChatServiceSessionCreation:
    """Tests for ChatService session creation."""

    def test_create_session_returns_uuid(self, mock_user_db_session):
        """Test that create_session returns a valid UUID."""
        from src.local_deep_research.chat.service import ChatService

        mock_user_db_session.add = MagicMock()
        mock_user_db_session.commit = MagicMock()

        service = ChatService(username="testuser")
        session_id = service.create_session()

        # Verify it's a valid UUID format
        assert session_id is not None
        try:
            uuid.UUID(session_id)
        except ValueError:
            pytest.fail("create_session did not return a valid UUID")

    def test_create_session_with_initial_query(self, mock_user_db_session):
        """Test creating a session with an initial query."""
        from src.local_deep_research.chat.service import ChatService

        mock_user_db_session.add = MagicMock()
        mock_user_db_session.commit = MagicMock()

        service = ChatService(username="testuser")
        initial_query = "What is quantum computing?"
        session_id = service.create_session(initial_query=initial_query)

        assert session_id is not None
        # Verify that session was added
        mock_user_db_session.add.assert_called()

    def test_create_session_with_custom_title(self, mock_user_db_session):
        """Test creating a session with a custom title."""
        from src.local_deep_research.chat.service import ChatService

        mock_user_db_session.add = MagicMock()
        mock_user_db_session.commit = MagicMock()

        service = ChatService(username="testuser")
        custom_title = "My Research Session"
        session_id = service.create_session(title=custom_title)

        assert session_id is not None
        # The session should be added to the database
        mock_user_db_session.add.assert_called()

    def test_create_session_generates_title_from_query(
        self, mock_user_db_session, long_query_text
    ):
        """Test that title is generated from query and truncated to 100 chars + '...'."""
        from src.local_deep_research.chat.service import ChatService

        mock_user_db_session.add = MagicMock()
        mock_user_db_session.commit = MagicMock()

        service = ChatService(username="testuser")
        session_id = service.create_session(initial_query=long_query_text)

        assert session_id is not None
        # The session was added (title truncation happens in service)
        mock_user_db_session.add.assert_called()

        # Get the added session and verify title length.
        # ChatSession always has a `title` column — drop the hasattr guard
        # so a regression that stops populating it surfaces here instead
        # of silently passing.
        added_session = mock_user_db_session.add.call_args[0][0]
        assert len(long_query_text) > 100, (
            "test fixture must exceed the 100-char truncation threshold"
        )
        assert len(added_session.title) <= 103  # 100 chars + "..."

    @freeze_time("2024-01-15 14:30:00")
    def test_create_session_generates_title_without_query(
        self, mock_user_db_session
    ):
        """Test that title is generated in format 'Chat YYYY-MM-DD HH:MM' when no query."""
        from src.local_deep_research.chat.service import ChatService

        mock_user_db_session.add = MagicMock()
        mock_user_db_session.commit = MagicMock()

        service = ChatService(username="testuser")
        session_id = service.create_session()

        assert session_id is not None
        mock_user_db_session.add.assert_called()

        # Get the added session and verify title format
        added_session = mock_user_db_session.add.call_args[0][0]
        expected_title = "Chat 2024-01-15 14:30"
        assert added_session.title == expected_title

    def test_create_session_initializes_accumulated_context(
        self, mock_user_db_session
    ):
        """Test that accumulated_context is initialized with proper structure."""
        from src.local_deep_research.chat.service import ChatService

        mock_user_db_session.add = MagicMock()
        mock_user_db_session.commit = MagicMock()

        service = ChatService(username="testuser")
        session_id = service.create_session()

        assert session_id is not None
        # Verify the session was added with accumulated_context
        added_session = mock_user_db_session.add.call_args[0][0]
        # Actual implementation initializes with this structure
        expected_context = {
            "key_entities": [],
            "topics": [],
            "summary": "",
        }
        assert added_session.accumulated_context == expected_context


class TestChatServiceAddMessage:
    """Tests for ChatService add_message method."""

    def test_add_message_returns_uuid(
        self, mock_user_db_session, mock_chat_session
    ):
        """Test that add_message returns a valid UUID."""
        from src.local_deep_research.chat.service import ChatService

        # Set up query mock to return session (handles with_for_update)
        setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
        mock_user_db_session.add = MagicMock()
        mock_user_db_session.commit = MagicMock()

        service = ChatService(username="testuser")
        message_id = service.add_message(
            session_id=mock_chat_session.id,
            role="user",
            content="Test message",
            message_type="query",
        )

        assert message_id is not None
        try:
            uuid.UUID(message_id)
        except ValueError:
            pytest.fail("add_message did not return a valid UUID")

    def test_add_message_increments_sequence_number(
        self, mock_user_db_session, mock_chat_session
    ):
        """Test that sequence number increments with each message."""
        from src.local_deep_research.chat.service import ChatService

        mock_chat_session.message_count = 5
        setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
        mock_user_db_session.add = MagicMock()
        mock_user_db_session.commit = MagicMock()

        service = ChatService(username="testuser")
        message_id = service.add_message(
            session_id=mock_chat_session.id,
            role="user",
            content="Test message",
            message_type="query",
        )

        assert message_id is not None
        # Verify the added message has correct sequence number
        added_message = mock_user_db_session.add.call_args[0][0]
        assert added_message.sequence_number == 6

    def test_add_message_updates_session_message_count(
        self, mock_user_db_session, mock_chat_session
    ):
        """Test that session message_count is updated."""
        from src.local_deep_research.chat.service import ChatService

        mock_chat_session.message_count = 5
        setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
        mock_user_db_session.add = MagicMock()
        mock_user_db_session.commit = MagicMock()

        service = ChatService(username="testuser")
        service.add_message(
            session_id=mock_chat_session.id,
            role="user",
            content="Test message",
            message_type="query",
        )

        # Session message_count should be incremented
        assert mock_chat_session.message_count == 6

    def test_add_message_raises_for_invalid_session(self, mock_user_db_session):
        """Test that add_message raises ValueError for non-existent session."""
        from src.local_deep_research.chat.service import ChatService

        # Set up query mock to return None (session not found)
        setup_query_mock_with_session(mock_user_db_session, None)

        service = ChatService(username="testuser")

        with pytest.raises(ValueError) as exc_info:
            service.add_message(
                session_id="nonexistent-session",
                role="user",
                content="Test message",
                message_type="query",
            )

        assert "not found" in str(exc_info.value)


class TestChatServiceContextAccumulation:
    """Tests for ChatService context accumulation methods."""

    def test_update_accumulated_context_adds_entities(
        self, mock_user_db_session, mock_chat_session
    ):
        """Test that new entities are added to accumulated context."""
        from src.local_deep_research.chat.service import ChatService

        mock_chat_session.accumulated_context = {
            "key_entities": ["entity1"],
            "topics": [],
            "summary": "",
        }
        setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
        mock_user_db_session.commit = MagicMock()

        service = ChatService(username="testuser")
        # Use the actual method signature with individual params
        result = service.update_accumulated_context(
            session_id=mock_chat_session.id,
            new_entities=["entity2", "entity3"],
            new_topics=["topic1"],
            summary_addition="New summary",
        )

        assert result is True
        # Verify entities were added
        assert (
            "entity1" in mock_chat_session.accumulated_context["key_entities"]
        )
        assert (
            "entity2" in mock_chat_session.accumulated_context["key_entities"]
        )
        assert (
            "entity3" in mock_chat_session.accumulated_context["key_entities"]
        )

    def test_update_accumulated_context_limits_entities_to_50(
        self, mock_user_db_session, mock_chat_session
    ):
        """Test that entities are limited to 50."""
        from src.local_deep_research.chat.service import ChatService

        mock_chat_session.accumulated_context = {
            "key_entities": [],
            "topics": [],
            "summary": "",
        }
        setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
        mock_user_db_session.commit = MagicMock()

        service = ChatService(username="testuser")
        # Add 60 entities
        many_entities = [f"entity_{i}" for i in range(60)]
        service.update_accumulated_context(
            session_id=mock_chat_session.id,
            new_entities=many_entities,
        )

        # Entities should be capped at 50
        assert len(mock_chat_session.accumulated_context["key_entities"]) <= 50

    def test_update_accumulated_context_limits_topics_to_20(
        self, mock_user_db_session, mock_chat_session
    ):
        """Test that topics are limited to 20."""
        from src.local_deep_research.chat.service import ChatService

        mock_chat_session.accumulated_context = {
            "key_entities": [],
            "topics": [],
            "summary": "",
        }
        setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
        mock_user_db_session.commit = MagicMock()

        service = ChatService(username="testuser")
        # Add 25 topics
        many_topics = [f"topic_{i}" for i in range(25)]
        service.update_accumulated_context(
            session_id=mock_chat_session.id,
            new_topics=many_topics,
        )

        # Topics should be capped at 20
        assert len(mock_chat_session.accumulated_context["topics"]) <= 20

    def test_update_accumulated_context_truncates_summary_to_8000_chars(
        self, mock_user_db_session, mock_chat_session
    ):
        """Test that summary is truncated to 8000 characters."""
        from src.local_deep_research.chat.service import ChatService

        mock_chat_session.accumulated_context = {
            "key_entities": [],
            "topics": [],
            "summary": "",
        }
        setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
        mock_user_db_session.commit = MagicMock()

        service = ChatService(username="testuser")
        # Add a 9000 character summary
        long_summary = "A" * 9000
        service.update_accumulated_context(
            session_id=mock_chat_session.id,
            summary_addition=long_summary,
        )

        # Summary should be capped at 8000 characters
        assert len(mock_chat_session.accumulated_context["summary"]) <= 8000


class TestChatServiceGetSession:
    """Tests for ChatService get_session method."""

    def test_get_session_returns_session_dict(
        self, mock_user_db_session, mock_chat_session
    ):
        """Test that get_session returns the correct session as a dict."""
        from src.local_deep_research.chat.service import ChatService

        query_mock = MagicMock()
        query_mock.filter_by.return_value.first.return_value = mock_chat_session
        mock_user_db_session.query.return_value = query_mock

        service = ChatService(username="testuser")
        result = service.get_session(mock_chat_session.id)

        assert result is not None
        # get_session returns a dict, not an object
        assert isinstance(result, dict)
        assert result["id"] == mock_chat_session.id

    def test_get_session_raises_not_found_for_nonexistent(
        self, mock_user_db_session
    ):
        """Test that get_session raises ChatSessionNotFound for missing rows."""
        import pytest
        from src.local_deep_research.chat.service import (
            ChatService,
            ChatSessionNotFound,
        )

        query_mock = MagicMock()
        query_mock.filter_by.return_value.first.return_value = None
        mock_user_db_session.query.return_value = query_mock

        service = ChatService(username="testuser")

        with pytest.raises(ChatSessionNotFound):
            service.get_session("nonexistent-id")


class TestChatServiceGetSessionMessages:
    """Tests for ChatService get_session_messages method."""

    def _setup_query_side_effect(
        self, mock_user_db_session, mock_messages, mock_steps=()
    ):
        """get_session_messages does TWO queries (ChatMessage
        + ChatProgressStep). Helper sets up the side_effect to dispatch."""
        from src.local_deep_research.database.models import (
            ChatMessage,
            ChatProgressStep,
        )

        def query_side_effect(model):
            q = MagicMock()
            # Service applies SQL-level LIMIT now (was Python slice). Chain
            # is: query → filter_by → order_by → limit → all
            if model is ChatMessage:
                (
                    q.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value
                ) = list(mock_messages)
            elif model is ChatProgressStep:
                (
                    q.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value
                ) = list(mock_steps)
            return q

        mock_user_db_session.query.side_effect = query_side_effect

    def test_get_session_messages_returns_ordered_messages(
        self, mock_user_db_session, sample_messages
    ):
        """Test that get_session_messages returns messages in order."""
        from src.local_deep_research.chat.service import ChatService

        mock_messages = []
        for msg in sample_messages:
            mock_msg = MagicMock()
            for key, value in msg.items():
                setattr(mock_msg, key, value)
            mock_messages.append(mock_msg)

        self._setup_query_side_effect(mock_user_db_session, mock_messages)

        service = ChatService(username="testuser")
        result = service.get_session_messages("session-123")

        assert len(result) == len(sample_messages)

    def test_get_session_messages_returns_empty_for_no_messages(
        self, mock_user_db_session
    ):
        """Test that get_session_messages returns empty list when no messages."""
        from src.local_deep_research.chat.service import ChatService

        self._setup_query_side_effect(mock_user_db_session, [], [])

        service = ChatService(username="testuser")
        result = service.get_session_messages("session-123")

        assert result == []

    def test_get_session_messages_with_pagination(
        self, mock_user_db_session, sample_messages
    ):
        """Test that get_session_messages respects limit and offset."""
        from src.local_deep_research.chat.service import ChatService

        mock_messages = []
        for msg in sample_messages[:2]:
            mock_msg = MagicMock()
            for key, value in msg.items():
                setattr(mock_msg, key, value)
            mock_messages.append(mock_msg)

        self._setup_query_side_effect(mock_user_db_session, mock_messages)

        service = ChatService(username="testuser")
        result = service.get_session_messages("session-123", limit=2, offset=0)

        assert len(result) == 2

    def test_get_session_messages_merges_step_rows(self, mock_user_db_session):
        """get_session_messages MUST merge ChatProgressStep
        rows from chat_progress_steps into the response with
        message_type='step', so chat.js's existing filter and the
        in-flight reload UX continue to work.

        Regression: chat.js fetchFormattedMessage filters
        ``m.message_type !== 'step'`` to skip per-iteration progress
        milestones. The merged stream must surface step entries with
        that exact discriminator value.
        """
        from src.local_deep_research.chat.service import ChatService
        from src.local_deep_research.database.models import (
            ChatMessage,
            ChatProgressStep,
        )

        from datetime import datetime

        msgs = [
            MagicMock(
                id="resp-1",
                session_id="session-123",
                role="assistant",
                message_type="response",
                content="Photosynthesis is the process by which plants...",
                research_id="r-1",
                sequence_number=1,
                created_at=datetime(2024, 1, 1, 12, 0, 5),
            ),
        ]
        steps = [
            MagicMock(
                id="step-uuid-1",
                session_id="session-123",
                research_id="r-1",
                phase="search",
                content="Starting research process",
                sequence_number=1,
                created_at=datetime(2024, 1, 1, 12, 0, 1),
            ),
            MagicMock(
                id="step-uuid-2",
                session_id="session-123",
                research_id="r-1",
                phase="search",
                content="Tool: web_search",
                sequence_number=2,
                created_at=datetime(2024, 1, 1, 12, 0, 2),
            ),
        ]

        def query_side_effect(model):
            q = MagicMock()
            # Chain: filter_by → order_by → limit → all (service applies
            # SQL-level LIMIT now).
            if model is ChatMessage:
                (
                    q.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value
                ) = msgs
            elif model is ChatProgressStep:
                (
                    q.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value
                ) = steps
            return q

        mock_user_db_session.query.side_effect = query_side_effect

        service = ChatService(username="testuser")
        result = service.get_session_messages("session-123")

        # Merged + sorted by created_at: 2 steps then the response.
        assert len(result) == 3
        types = [r["message_type"] for r in result]
        assert types == ["step", "step", "response"]
        # Step IDs are prefixed to avoid collision with chat_messages ids.
        assert result[0]["id"].startswith("step-")
        # JS filter mirror: find the first assistant + has-content + non-step.
        formatted = next(
            (
                m
                for m in result
                if m["role"] == "assistant"
                and m["research_id"] == "r-1"
                and m["content"]
                and m["message_type"] != "step"
            ),
            None,
        )
        assert formatted is not None
        assert formatted["id"] == "resp-1"
        assert "Photosynthesis" in formatted["content"]


class TestChatServiceTitleGeneration:
    """Tests for the split between sync fallback title and async LLM title."""

    def test_create_session_does_not_invoke_llm(self, mock_user_db_session):
        """create_session must not block on the LLM even when settings ask for it.

        Regression guard: the deferred-title refactor moved LLM title
        generation out of the POST /api/chat/sessions path so the HTTP
        response isn't blocked on a model round-trip.
        """
        from src.local_deep_research.chat.service import ChatService

        mock_user_db_session.add = MagicMock()
        mock_user_db_session.commit = MagicMock()

        settings_snapshot = {"chat.llm_title_generation": {"value": True}}

        with patch(
            "src.local_deep_research.config.llm_config.get_llm"
        ) as mock_get_llm:
            service = ChatService(username="testuser")
            service.create_session(
                initial_query="Does quantum entanglement violate locality?",
                settings_snapshot=settings_snapshot,
            )

        mock_get_llm.assert_not_called()

    def test_regenerate_title_with_llm_updates_session(
        self, mock_user_db_session, mock_chat_session
    ):
        """regenerate_title_with_llm writes the LLM-returned title to the session."""
        from src.local_deep_research.chat.service import ChatService

        # Title must equal the non-LLM fallback so the idempotency check
        # at the top of regenerate_title_with_llm doesn't short-circuit.
        query = "Does quantum entanglement violate locality?"
        mock_chat_session.title = query  # fallback for short queries == query
        setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
        mock_user_db_session.commit = MagicMock()

        fake_llm = MagicMock()
        fake_llm.invoke.return_value = MagicMock(
            content="  Quantum Locality Question  "
        )

        settings_snapshot = {"chat.llm_title_generation": {"value": True}}

        with (
            patch(
                "src.local_deep_research.config.llm_config.get_llm",
                return_value=fake_llm,
            ),
            patch(
                "src.local_deep_research.config.thread_settings.get_setting_from_snapshot",
                return_value=True,
            ),
        ):
            service = ChatService(username="testuser")
            new_title = service.regenerate_title_with_llm(
                mock_chat_session.id,
                query=query,
                settings_snapshot=settings_snapshot,
            )

        assert new_title == "Quantum Locality Question"
        # The session object passed to the DB got the new title
        assert mock_chat_session.title == "Quantum Locality Question"

    def test_regenerate_title_strips_newlines_from_llm_output(
        self, mock_user_db_session, mock_chat_session
    ):
        """Regression guard: LLM-generated titles must have CR/LF flattened.

        The LLM-generated title is interpolated into loguru f-strings
        (the "title already set" log line) and into document.title /
        chatTitle.textContent on the client. An LLM that returns a title
        containing a newline would otherwise forge what looks like a
        second log entry in aggregators (log injection). The stored
        title must have all CR/LF flattened to spaces.
        """
        from src.local_deep_research.chat.service import ChatService

        query = "Does quantum entanglement violate locality?"
        mock_chat_session.title = query  # equals fallback → not skipped
        setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
        mock_user_db_session.commit = MagicMock()

        # Hostile/sloppy LLM output: embedded LF and CRLF that, unstripped,
        # would split the audit log line into three.
        fake_llm = MagicMock()
        fake_llm.invoke.return_value = MagicMock(
            content="Quantum Locality\nINFO faked log line\r\ntrailing"
        )

        settings_snapshot = {"chat.llm_title_generation": {"value": True}}

        with (
            patch(
                "src.local_deep_research.config.llm_config.get_llm",
                return_value=fake_llm,
            ),
            patch(
                "src.local_deep_research.config.thread_settings.get_setting_from_snapshot",
                return_value=True,
            ),
        ):
            service = ChatService(username="testuser")
            new_title = service.regenerate_title_with_llm(
                mock_chat_session.id,
                query=query,
                settings_snapshot=settings_snapshot,
            )

        assert new_title is not None
        assert "\n" not in new_title, "newline must be stripped from title"
        assert "\r" not in new_title, (
            "carriage return must be stripped from title"
        )
        # The visible content survives, just flattened to spaces.
        assert new_title.startswith("Quantum Locality")
        # And the same flattened value is what landed on the session row.
        assert mock_chat_session.title == new_title

    def test_regenerate_title_skipped_when_user_has_edited_title(
        self, mock_user_db_session, mock_chat_session
    ):
        """If the title no longer matches the non-LLM fallback (because the
        user manually edited it, or a sibling tab's LLM-gen already ran),
        skip the LLM call to avoid burning credits to overwrite their work."""
        from src.local_deep_research.chat.service import ChatService

        query = "Does quantum entanglement violate locality?"
        mock_chat_session.title = "My Custom Title"  # user-edited
        setup_query_mock_with_session(mock_user_db_session, mock_chat_session)

        fake_llm = MagicMock()
        fake_llm.invoke.return_value = MagicMock(content="LLM Title")

        with (
            patch(
                "src.local_deep_research.config.llm_config.get_llm",
                return_value=fake_llm,
            ),
            patch(
                "src.local_deep_research.config.thread_settings.get_setting_from_snapshot",
                return_value=True,
            ),
        ):
            service = ChatService(username="testuser")
            new_title = service.regenerate_title_with_llm(
                mock_chat_session.id,
                query=query,
                settings_snapshot={
                    "chat.llm_title_generation": {"value": True}
                },
            )

        assert new_title is None
        fake_llm.invoke.assert_not_called()
        # User's custom title is preserved
        assert mock_chat_session.title == "My Custom Title"

    def test_regenerate_title_with_llm_returns_none_for_empty_query(
        self, mock_user_db_session
    ):
        """Empty query → None (no LLM call, no DB write)."""
        from src.local_deep_research.chat.service import ChatService

        service = ChatService(username="testuser")
        assert (
            service.regenerate_title_with_llm(
                "sess-1", query="", settings_snapshot={}
            )
            is None
        )
        assert (
            service.regenerate_title_with_llm(
                "sess-1", query=None, settings_snapshot={}
            )
            is None
        )


class TestChatMessageEnumValidation:
    """Real-SQLite (not mocked) coverage for ChatRole/ChatMessageType validation.

    The manual `valid_roles` / `valid_message_types` set checks were
    replaced by `ChatRole(value)` / `ChatMessageType(value)` coercion that
    re-raises as ValueError, preserving the HTTP 400 route-layer contract.
    """

    def _setup_real_db(self, tmp_path):
        """File-backed SQLite with chat tables + a seeded session."""
        import uuid as _uuid
        from datetime import datetime as _dt, UTC as _utc
        from sqlalchemy import create_engine
        from sqlalchemy.orm import sessionmaker
        from src.local_deep_research.database.models import (
            Base,
            ChatSession,
        )

        db_path = tmp_path / "enum_validation.db"
        engine = create_engine(f"sqlite:///{db_path}")
        Base.metadata.create_all(engine)

        Session = sessionmaker(bind=engine)
        session_id = str(_uuid.uuid4())
        with Session() as db:
            db.add(
                ChatSession(
                    id=session_id,
                    title="enum-test",
                    status="active",
                    accumulated_context={
                        "key_entities": [],
                        "topics": [],
                        "summary": "",
                    },
                    created_at=_dt.now(_utc),
                    message_count=0,
                )
            )
            db.commit()

        return engine, Session, session_id

    def test_add_message_rejects_invalid_role(self, tmp_path):
        """Unknown role → ValueError (which routes map to HTTP 400)."""
        from contextlib import contextmanager
        from src.local_deep_research.chat.service import ChatService

        engine, Session, session_id = self._setup_real_db(tmp_path)

        @contextmanager
        def real_get_user_db_session(username, password=None):
            with Session() as db:
                yield db

        service = ChatService(username="testuser")
        with patch(
            "src.local_deep_research.chat.service.get_user_db_session",
            real_get_user_db_session,
        ):
            with pytest.raises(ValueError, match="Invalid role"):
                service.add_message(
                    session_id=session_id,
                    role="usr",  # typo — not in ChatRole
                    content="hi",
                    message_type="query",
                )
        engine.dispose()

    def test_add_message_rejects_invalid_message_type(self, tmp_path):
        """Unknown message_type → ValueError."""
        from contextlib import contextmanager
        from src.local_deep_research.chat.service import ChatService

        engine, Session, session_id = self._setup_real_db(tmp_path)

        @contextmanager
        def real_get_user_db_session(username, password=None):
            with Session() as db:
                yield db

        service = ChatService(username="testuser")
        with patch(
            "src.local_deep_research.chat.service.get_user_db_session",
            real_get_user_db_session,
        ):
            with pytest.raises(ValueError, match="Invalid message_type"):
                service.add_message(
                    session_id=session_id,
                    role="user",
                    content="hi",
                    message_type="queryz",  # typo
                )
        engine.dispose()

    def test_add_message_accepts_string_values(self, tmp_path):
        """Valid string values are coerced to enum members transparently."""
        from contextlib import contextmanager
        from src.local_deep_research.chat.service import ChatService
        from src.local_deep_research.database.models import (
            ChatMessage,
            ChatMessageType,
            ChatRole,
        )

        engine, Session, session_id = self._setup_real_db(tmp_path)

        @contextmanager
        def real_get_user_db_session(username, password=None):
            with Session() as db:
                yield db

        service = ChatService(username="testuser")
        with patch(
            "src.local_deep_research.chat.service.get_user_db_session",
            real_get_user_db_session,
        ):
            service.add_message(
                session_id=session_id,
                role="user",
                content="hi",
                message_type="query",
            )

        with Session() as db:
            msg = db.query(ChatMessage).filter_by(session_id=session_id).one()
        assert msg.role is ChatRole.USER
        assert msg.message_type is ChatMessageType.QUERY
        # `(str, enum.Enum)` members compare equal to their string value.
        assert msg.role == "user"
        assert msg.message_type == "query"
        engine.dispose()


class TestChatServiceGetInProgressResearchId:
    """Tests for ``ChatService.get_in_progress_research_id``.

    The client (chat.js ``loadSession``) reads this value to restore the
    "thinking" indicator on reload. The method queries ResearchHistory
    via the partial-unique index
    ``ux_research_history_chat_session_in_progress`` (migration 0010) so
    at most one row can match.
    """

    def test_returns_none_when_no_in_progress_research(
        self, mock_user_db_session
    ):
        from src.local_deep_research.chat.service import ChatService

        mock_user_db_session.query.return_value.filter.return_value.first.return_value = None

        service = ChatService(username="testuser")
        assert service.get_in_progress_research_id("session-123") is None

    def test_returns_id_when_in_progress_research_exists(
        self, mock_user_db_session
    ):
        from src.local_deep_research.chat.service import ChatService

        mock_user_db_session.query.return_value.filter.return_value.first.return_value = (
            "research-abc-123",
        )

        service = ChatService(username="testuser")
        assert (
            service.get_in_progress_research_id("session-123")
            == "research-abc-123"
        )

    def test_db_error_propagates(self, mock_user_db_session):
        """DB exceptions must propagate so the route handler can return
        a 500 the client surfaces as an error banner. The previous
        return-None behaviour was indistinguishable from "no research
        running" — the UI silently re-enabled the send button and the
        user's double-submit hit the unique-index guard. The strict
        behaviour matches every other ChatService query method."""
        import pytest
        from sqlalchemy.exc import OperationalError

        from src.local_deep_research.chat.service import ChatService

        mock_user_db_session.query.side_effect = OperationalError(
            "stmt", {}, Exception("boom")
        )

        service = ChatService(username="testuser")
        with pytest.raises(OperationalError):
            service.get_in_progress_research_id("session-123")

    def test_query_filters_by_in_progress_status_and_session_id(
        self, mock_user_db_session
    ):
        """Locks the query shape against accidental regressions — the
        partial-unique-index lookup depends on the predicate matching
        the index's WHERE clause exactly."""
        from src.local_deep_research.chat.service import ChatService
        from src.local_deep_research.constants import ResearchStatus
        from src.local_deep_research.database.models import ResearchHistory

        query_mock = MagicMock()
        query_mock.filter.return_value.first.return_value = None
        mock_user_db_session.query.return_value = query_mock

        service = ChatService(username="testuser")
        service.get_in_progress_research_id("session-xyz")

        # Selected ResearchHistory.id (not the whole row)
        mock_user_db_session.query.assert_called_once_with(ResearchHistory.id)
        # Filter call received both clauses; we don't assert SQL text
        # equality (SQLAlchemy ClauseElements compare by identity), only
        # that two clauses were passed and IN_PROGRESS is referenced.
        filter_args = query_mock.filter.call_args[0]
        assert len(filter_args) == 2
        # Verify the actual bound values rather than just that the enum exists
        # (the previous `is not None` assertion was a tautology). Each clause is
        # a BinaryExpression whose .right is a BindParameter carrying the
        # compared value; this catches a regression that swaps IN_PROGRESS for
        # another status or drops the session-id predicate.
        bind_values = [
            getattr(getattr(c, "right", None), "value", None)
            for c in filter_args
        ]
        assert ResearchStatus.IN_PROGRESS in bind_values
        assert "session-xyz" in bind_values
