"""
Tests for QueueProcessorV2 — real integration tests for core queue orchestration.

These tests exercise the actual class methods with proper mocking of external
dependencies (database, password store, research service), unlike the fake tests
in test_processor_v2_core/operations/research.py that only test Python primitives.

Tests cover:
- notify_research_queued: direct vs queue mode branching
- notify_research_completed: status updates + notifications
- notify_research_failed: error message defaulting, sanitization
- _process_queue_loop: user:session parsing, removal logic
- _process_user_queue: slot calculation, empty queue, no password
- _start_research: new vs legacy settings format
- process_pending_operations_for_user: progress/error updates, rollback
"""

import time
import threading
from unittest.mock import Mock, MagicMock, patch

import pytest


MODULE = "local_deep_research.web.queue.processor_v2"
SETTINGS_MGR = "local_deep_research.settings.manager.SettingsManager"


def _make_processor():
    """Create a QueueProcessorV2 instance without triggering module-level side effects."""
    from local_deep_research.web.queue.processor_v2 import QueueProcessorV2

    return QueueProcessorV2(check_interval=1)


# ---------------------------------------------------------------------------
# notify_research_queued — direct vs queue mode branching
# ---------------------------------------------------------------------------


class TestNotifyResearchQueuedDirectMode:
    """Tests for notify_research_queued when queue_mode='direct'."""

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_direct_mode_slots_available_starts_directly(
        self, mock_pw_store, mock_db_mgr, mock_get_session
    ):
        """direct mode + slots available -> calls _start_research_directly."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.return_value = Mock()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        # SettingsManager says direct mode, max 3 concurrent
        mock_settings = Mock()
        mock_settings.get_setting.side_effect = lambda key, default: {
            "app.queue_mode": "direct",
            "app.max_concurrent_researches": 3,
        }.get(key, default)

        # 0 active researches -> has slots
        mock_session.query.return_value.filter_by.return_value.count.return_value = 0

        with patch(SETTINGS_MGR, return_value=mock_settings):
            with patch.object(
                processor, "_start_research_directly"
            ) as mock_start:
                processor.notify_research_queued(
                    "alice",
                    "r-001",
                    session_id="sess1",
                    query="test query",
                )
                mock_start.assert_called_once()

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_direct_mode_slots_full_falls_back_to_queue(
        self, mock_pw_store, mock_db_mgr, mock_get_session
    ):
        """direct mode + slots full -> queues instead."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.return_value = Mock()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        mock_settings = Mock()
        mock_settings.get_setting.side_effect = lambda key, default: {
            "app.queue_mode": "direct",
            "app.max_concurrent_researches": 2,
        }.get(key, default)

        # 2 active = full at max 2
        mock_session.query.return_value.filter_by.return_value.count.return_value = 2

        with patch(SETTINGS_MGR, return_value=mock_settings):
            with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
                mock_qs = Mock()
                mock_qs_class.return_value = mock_qs

                processor.notify_research_queued(
                    "alice",
                    "r-002",
                    session_id="sess1",
                    query="test",
                )
                mock_qs.add_task_metadata.assert_called_once()

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_queue_mode_always_queues(
        self, mock_pw_store, mock_db_mgr, mock_get_session
    ):
        """queue_mode='queue' -> always queues regardless of slots."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.return_value = Mock()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        mock_settings = Mock()
        mock_settings.get_setting.side_effect = lambda key, default: {
            "app.queue_mode": "queue",
            "app.max_concurrent_researches": 3,
        }.get(key, default)

        with patch(SETTINGS_MGR, return_value=mock_settings):
            with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
                mock_qs = Mock()
                mock_qs_class.return_value = mock_qs

                with patch.object(
                    processor, "_start_research_directly"
                ) as mock_start:
                    processor.notify_research_queued(
                        "alice",
                        "r-003",
                        session_id="sess1",
                        query="test",
                    )
                    mock_start.assert_not_called()
                    mock_qs.add_task_metadata.assert_called_once()

    @patch(f"{MODULE}.get_user_db_session")
    def test_missing_session_id_falls_back_to_queue(self, mock_get_session):
        """No session_id kwarg -> falls back to queue mode."""
        processor = _make_processor()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
            mock_qs = Mock()
            mock_qs_class.return_value = mock_qs

            # No session_id in kwargs
            processor.notify_research_queued("alice", "r-004", query="test")
            mock_qs.add_task_metadata.assert_called_once()

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.session_password_store")
    def test_missing_password_falls_back_to_queue(
        self, mock_pw_store, mock_get_session
    ):
        """No password for session -> falls back to queue mode."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = None

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
            mock_qs = Mock()
            mock_qs_class.return_value = mock_qs

            processor.notify_research_queued(
                "alice", "r-005", session_id="sess1", query="test"
            )
            mock_qs.add_task_metadata.assert_called_once()

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_exception_in_direct_falls_back_to_queue(
        self, mock_pw_store, mock_db_mgr, mock_get_session
    ):
        """Exception during direct execution -> falls back to queue."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.side_effect = RuntimeError("DB error")

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
            mock_qs = Mock()
            mock_qs_class.return_value = mock_qs

            processor.notify_research_queued(
                "alice", "r-006", session_id="sess1", query="test"
            )
            mock_qs.add_task_metadata.assert_called_once()

    @patch(f"{MODULE}.get_user_db_session")
    def test_no_kwargs_queues_directly(self, mock_get_session):
        """No kwargs at all -> goes straight to queue fallback."""
        processor = _make_processor()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
            mock_qs = Mock()
            mock_qs_class.return_value = mock_qs

            processor.notify_research_queued("alice", "r-007")
            mock_qs.add_task_metadata.assert_called_once()


# ---------------------------------------------------------------------------
# notify_research_completed — updates status + sends notification
# ---------------------------------------------------------------------------


class TestNotifyResearchCompleted:
    """Tests for notify_research_completed."""

    @patch(f"{MODULE}.send_research_completed_notification_from_session")
    @patch(f"{MODULE}.get_user_db_session")
    def test_updates_status_and_sends_notification(
        self, mock_get_session, mock_send_notif
    ):
        """Updates queue status to COMPLETED and sends notification."""
        processor = _make_processor()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
            mock_qs = Mock()
            mock_qs_class.return_value = mock_qs

            processor.notify_research_completed(
                "alice", "r-010", user_password="pw"
            )

            mock_qs.update_task_status.assert_called_once()
            mock_send_notif.assert_called_once_with(
                username="alice",
                research_id="r-010",
                db_session=mock_session,
            )

    @patch(f"{MODULE}.get_user_db_session")
    def test_exception_does_not_propagate(self, mock_get_session):
        """Exception in status update is caught silently."""
        processor = _make_processor()

        mock_get_session.side_effect = RuntimeError("DB unavailable")

        # Should not raise
        processor.notify_research_completed("alice", "r-011")

    @patch(f"{MODULE}.send_research_completed_notification_from_session")
    @patch(f"{MODULE}.get_user_db_session")
    def test_passes_password_to_session(
        self, mock_get_session, mock_send_notif
    ):
        """User password is passed to get_user_db_session."""
        processor = _make_processor()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService"):
            processor.notify_research_completed(
                "bob", "r-012", user_password="secret"
            )

        mock_get_session.assert_called_once_with("bob", "secret")


# ---------------------------------------------------------------------------
# notify_research_failed — error message defaulting, sanitization
# ---------------------------------------------------------------------------


class TestNotifyResearchFailed:
    """Tests for notify_research_failed."""

    @patch(f"{MODULE}.send_research_failed_notification_from_session")
    @patch(f"{MODULE}.get_user_db_session")
    def test_sends_error_message_to_queue_service(
        self, mock_get_session, mock_send_notif
    ):
        """Passes error_message to queue service update."""
        processor = _make_processor()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
            mock_qs = Mock()
            mock_qs_class.return_value = mock_qs

            processor.notify_research_failed(
                "alice", "r-020", error_message="OOM", user_password="pw"
            )

            mock_qs.update_task_status.assert_called_once()
            args, kwargs = mock_qs.update_task_status.call_args
            assert (
                kwargs.get("error_message") == "OOM" or args[2]
                if len(args) > 2
                else True
            )

    @patch(f"{MODULE}.send_research_failed_notification_from_session")
    @patch(f"{MODULE}.get_user_db_session")
    def test_defaults_error_message_to_unknown(
        self, mock_get_session, mock_send_notif
    ):
        """None error_message defaults to 'Unknown error' for notification."""
        processor = _make_processor()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService"):
            processor.notify_research_failed(
                "alice", "r-021", user_password="pw"
            )

        mock_send_notif.assert_called_once_with(
            username="alice",
            research_id="r-021",
            error_message="Unknown error",
            db_session=mock_session,
        )

    @patch(f"{MODULE}.get_user_db_session")
    def test_exception_does_not_propagate(self, mock_get_session):
        """Exception in failure handling is caught."""
        processor = _make_processor()

        mock_get_session.side_effect = RuntimeError("DB error")

        # Should not raise
        processor.notify_research_failed("alice", "r-022", error_message="bad")


# ---------------------------------------------------------------------------
# _process_queue_loop — user:session parsing, removal logic
# ---------------------------------------------------------------------------


class TestProcessQueueLoop:
    """Tests for _process_queue_loop."""

    def test_parses_user_session_format(self):
        """Correctly parses (username, session_id) tuple entries."""
        processor = _make_processor()
        processor._users_to_check.add(("alice", "sess-abc"))

        with patch.object(
            processor, "_process_user_queue", return_value=True
        ) as mock_pq:
            # Run one iteration then stop
            processor.running = True

            def stop_after_one():
                time.sleep(0.05)
                processor.running = False

            threading.Thread(target=stop_after_one).start()
            processor._process_queue_loop()

            mock_pq.assert_called_with("alice", "sess-abc")

    def test_removes_users_with_empty_queues(self):
        """Users whose queues are empty get removed from check set."""
        processor = _make_processor()
        processor._users_to_check.add(("alice", "sess1"))
        processor._users_to_check.add(("bob", "sess2"))

        def side_effect(username, session_id):
            return username == "alice"  # alice's queue is empty

        with patch.object(
            processor, "_process_user_queue", side_effect=side_effect
        ):
            processor.running = True

            def stop_after_one():
                time.sleep(0.05)
                processor.running = False

            threading.Thread(target=stop_after_one).start()
            processor._process_queue_loop()

        assert ("alice", "sess1") not in processor._users_to_check
        assert ("bob", "sess2") in processor._users_to_check

    def test_keeps_users_on_error(self):
        """Users are kept if _process_user_queue raises."""
        processor = _make_processor()
        processor._users_to_check.add(("alice", "sess1"))

        with patch.object(
            processor,
            "_process_user_queue",
            side_effect=RuntimeError("transient"),
        ):
            processor.running = True

            def stop_after_one():
                time.sleep(0.05)
                processor.running = False

            threading.Thread(target=stop_after_one).start()
            processor._process_queue_loop()

        assert ("alice", "sess1") in processor._users_to_check


# ---------------------------------------------------------------------------
# _process_user_queue — slot calculation, empty queue, no password
# ---------------------------------------------------------------------------


class TestProcessUserQueue:
    """Tests for _process_user_queue."""

    @patch(f"{MODULE}.session_password_store")
    def test_no_password_returns_true(self, mock_pw_store):
        """No password -> returns True (remove from checking)."""
        processor = _make_processor()
        mock_pw_store.get_session_password.return_value = None

        result = processor._process_user_queue("alice", "sess1")
        assert result is True

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_empty_queue_returns_true(
        self, mock_pw_store, mock_db_mgr, mock_get_session
    ):
        """Empty queue -> returns True."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.return_value = Mock()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        mock_settings = Mock()
        mock_settings.get_setting.return_value = 3

        mock_qs = Mock()
        mock_qs.get_queue_status.return_value = {
            "active_tasks": 0,
            "queued_tasks": 0,
        }

        with patch(SETTINGS_MGR, return_value=mock_settings):
            with patch(f"{MODULE}.UserQueueService", return_value=mock_qs):
                result = processor._process_user_queue("alice", "sess1")

        assert result is True

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_no_slots_available_returns_false(
        self, mock_pw_store, mock_db_mgr, mock_get_session
    ):
        """No available slots -> returns False (keep checking)."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.return_value = Mock()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        mock_settings = Mock()
        mock_settings.get_setting.return_value = 2

        mock_qs = Mock()
        mock_qs.get_queue_status.return_value = {
            "active_tasks": 2,
            "queued_tasks": 1,
        }

        with patch(SETTINGS_MGR, return_value=mock_settings):
            with patch(f"{MODULE}.UserQueueService", return_value=mock_qs):
                result = processor._process_user_queue("alice", "sess1")

        assert result is False

    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_failed_db_open_returns_false(self, mock_pw_store, mock_db_mgr):
        """Failed database open -> returns False (keep checking)."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.return_value = None

        result = processor._process_user_queue("alice", "sess1")
        assert result is False


# ---------------------------------------------------------------------------
# _start_research — new vs legacy settings format
# ---------------------------------------------------------------------------


class TestStartResearch:
    """Tests for _start_research."""

    @patch(f"{MODULE}.start_research_process")
    def test_new_format_extracts_submission_and_settings(
        self, mock_start_research
    ):
        """New format: {submission: {...}, settings_snapshot: {...}}."""
        processor = _make_processor()

        mock_thread = Mock()
        mock_thread.ident = 12345
        mock_start_research.return_value = mock_thread

        mock_session = MagicMock()
        mock_research = Mock()
        mock_research.status = "queued"
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        queued = Mock()
        queued.research_id = "r-100"
        queued.query = "test query"
        queued.mode = "standard"
        queued.settings_snapshot = {
            "submission": {
                "model_provider": "openai",
                "model": "gpt-4",
                "strategy": "graph-based",
            },
            "settings_snapshot": {"setting_a": "value_a"},
        }

        with patch(f"{MODULE}.UserActiveResearch"):
            processor._start_research(mock_session, "alice", "pw", queued)

        # Verify start_research_process was called with new-format extracted params
        call_kwargs = mock_start_research.call_args.kwargs
        assert call_kwargs["model_provider"] == "openai"
        assert call_kwargs["model"] == "gpt-4"
        assert call_kwargs["strategy"] == "graph-based"
        assert call_kwargs["settings_snapshot"] == {"setting_a": "value_a"}

    @patch(f"{MODULE}.start_research_process")
    def test_legacy_format_uses_flat_dict(self, mock_start_research):
        """Legacy format: flat dict used as submission_params."""
        from local_deep_research.constants import ResearchStatus

        processor = _make_processor()

        mock_thread = Mock()
        mock_thread.ident = 12345
        mock_start_research.return_value = mock_thread

        mock_session = MagicMock()
        mock_research = Mock()
        mock_research.status = ResearchStatus.QUEUED
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        queued = Mock()
        queued.research_id = "r-101"
        queued.query = "legacy query"
        queued.mode = "standard"
        queued.settings_snapshot = {
            "model_provider": "anthropic",
            "model": "claude-3",
        }

        with patch(f"{MODULE}.UserActiveResearch"):
            processor._start_research(mock_session, "alice", "pw", queued)

        call_kwargs = mock_start_research.call_args.kwargs
        assert call_kwargs["model_provider"] == "anthropic"
        assert call_kwargs["model"] == "claude-3"
        assert call_kwargs["settings_snapshot"] == {}

    @patch(f"{MODULE}.start_research_process")
    def test_legacy_format_seeds_search_tool_from_search_engine(
        self, mock_start_research
    ):
        """A legacy-flat row that carries a search_engine seeds the (otherwise
        empty) settings_snapshot with search.tool, so the worker's egress build
        (resolve_run_primary_engine) doesn't fail closed on an empty snapshot
        and refuse a replayed pre-upgrade queued run."""
        from local_deep_research.constants import ResearchStatus

        processor = _make_processor()
        mock_thread = Mock()
        mock_thread.ident = 1
        mock_start_research.return_value = mock_thread

        mock_session = MagicMock()
        mock_research = Mock()
        mock_research.status = ResearchStatus.QUEUED
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        queued = Mock()
        queued.research_id = "r-102"
        queued.query = "legacy query"
        queued.mode = "standard"
        queued.settings_snapshot = {
            "model_provider": "anthropic",
            "search_engine": "pubmed",
        }

        with patch(f"{MODULE}.UserActiveResearch"):
            processor._start_research(mock_session, "alice", "pw", queued)

        call_kwargs = mock_start_research.call_args.kwargs
        assert call_kwargs["settings_snapshot"] == {"search.tool": "pubmed"}
        assert call_kwargs["search_engine"] == "pubmed"

    @patch(f"{MODULE}.start_research_process")
    def test_none_settings_snapshot_handled(self, mock_start_research):
        """None settings_snapshot treated as empty dict."""
        from local_deep_research.constants import ResearchStatus

        processor = _make_processor()

        mock_thread = Mock()
        mock_thread.ident = 12345
        mock_start_research.return_value = mock_thread

        mock_session = MagicMock()
        mock_research = Mock()
        mock_research.status = ResearchStatus.QUEUED
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        queued = Mock()
        queued.research_id = "r-102"
        queued.query = "test"
        queued.mode = "standard"
        queued.settings_snapshot = None

        with patch(f"{MODULE}.UserActiveResearch"):
            processor._start_research(mock_session, "alice", "pw", queued)

        call_kwargs = mock_start_research.call_args.kwargs
        assert call_kwargs["settings_snapshot"] == {}

    def test_research_not_found_raises(self):
        """Raises ValueError when research record not found."""
        processor = _make_processor()

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

        queued = Mock()
        queued.research_id = "r-999"
        queued.settings_snapshot = {}

        with pytest.raises(ValueError, match="not found"):
            processor._start_research(mock_session, "alice", "pw", queued)

    @patch(f"{MODULE}.start_research_process")
    def test_status_set_in_progress_before_spawn(self, mock_start_research):
        """IN_PROGRESS must be committed BEFORE start_research_process is
        called, so a fast thread cannot race ahead and have its terminal
        status overwritten by our post-spawn commit."""
        from local_deep_research.constants import ResearchStatus

        processor = _make_processor()

        mock_thread = Mock()
        mock_thread.ident = 999
        mock_start_research.return_value = mock_thread

        mock_session = MagicMock()
        mock_research = Mock()
        mock_research.status = ResearchStatus.QUEUED
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        # Record the status at the moment start_research_process is called.
        status_at_spawn = {}

        def _capture_status(*args, **kwargs):
            status_at_spawn["value"] = mock_research.status
            # Capture the commit count at the moment of spawn so we can
            # assert the IN_PROGRESS commit already happened, not merely
            # that some commit happened at some point in the method.
            status_at_spawn["commit_count"] = mock_session.commit.call_count
            return mock_thread

        mock_start_research.side_effect = _capture_status

        queued = Mock()
        queued.research_id = "r-race"
        queued.query = "q"
        queued.mode = "standard"
        queued.settings_snapshot = {}

        with patch(f"{MODULE}.UserActiveResearch"):
            processor._start_research(mock_session, "alice", "pw", queued)

        assert status_at_spawn["value"] == ResearchStatus.IN_PROGRESS
        # Exactly one commit (the IN_PROGRESS write) must have already
        # run by the time start_research_process is called.
        assert status_at_spawn["commit_count"] == 1

    @patch(f"{MODULE}.start_research_process")
    def test_spawn_failure_resets_status_to_queued(self, mock_start_research):
        """On genuine spawn failure (non-duplicate), status must be rolled
        back to QUEUED so the next retry sees a clean row and the
        research is not stuck IN_PROGRESS if the retry budget is
        eventually exhausted."""
        from local_deep_research.constants import ResearchStatus

        processor = _make_processor()

        mock_start_research.side_effect = RuntimeError("spawn failed")

        mock_session = MagicMock()
        mock_research = Mock()
        mock_research.status = ResearchStatus.QUEUED
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        queued = Mock()
        queued.research_id = "r-fail"
        queued.query = "q"
        queued.mode = "standard"
        queued.settings_snapshot = {}

        with pytest.raises(RuntimeError, match="spawn failed"):
            processor._start_research(mock_session, "alice", "pw", queued)

        assert mock_research.status == ResearchStatus.QUEUED
        # One commit for IN_PROGRESS before spawn, a second for the QUEUED
        # reset after spawn failed. Without the second commit the in-memory
        # attribute is rolled back but the DB still shows IN_PROGRESS, so
        # assert that both commits actually ran.
        assert mock_session.commit.call_count >= 2

    @patch(f"{MODULE}.start_research_process")
    def test_duplicate_research_error_does_not_reset_status(
        self, mock_start_research
    ):
        """DuplicateResearchError means a live thread already exists for
        this research_id (typically a retry after a post-spawn commit
        failure). Mutating status in that case would contradict the
        running thread — it must be left IN_PROGRESS."""
        from local_deep_research.constants import ResearchStatus
        from local_deep_research.exceptions import DuplicateResearchError

        processor = _make_processor()

        mock_start_research.side_effect = DuplicateResearchError(
            "thread already live"
        )

        mock_session = MagicMock()
        mock_research = Mock()
        mock_research.status = ResearchStatus.QUEUED
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        queued = Mock()
        queued.research_id = "r-dup"
        queued.query = "q"
        queued.mode = "standard"
        queued.settings_snapshot = {}

        with pytest.raises(DuplicateResearchError):
            processor._start_research(mock_session, "alice", "pw", queued)

        # Status must remain IN_PROGRESS (set before spawn, never reset).
        assert mock_research.status == ResearchStatus.IN_PROGRESS

    @patch(f"{MODULE}.start_research_process")
    def test_non_queued_status_raises_duplicate_without_spawning(
        self, mock_start_research
    ):
        """If _start_research is re-entered on a retry and finds the row
        already in a non-QUEUED state (IN_PROGRESS from a prior attempt
        whose post-spawn commit failed, or terminal COMPLETED/FAILED
        because the prior thread already finished and cleaned up), it
        must raise DuplicateResearchError *without* mutating status and
        *without* calling start_research_process. Otherwise we would
        overwrite a terminal status with IN_PROGRESS and spawn a second
        thread that re-runs the whole research."""
        from local_deep_research.constants import ResearchStatus
        from local_deep_research.exceptions import DuplicateResearchError

        for starting_status in (
            ResearchStatus.IN_PROGRESS,
            ResearchStatus.COMPLETED,
            ResearchStatus.FAILED,
            ResearchStatus.SUSPENDED,
        ):
            processor = _make_processor()
            mock_start_research.reset_mock()

            mock_session = MagicMock()
            mock_research = Mock()
            mock_research.status = starting_status
            mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

            queued = Mock()
            queued.research_id = f"r-stale-{starting_status}"
            queued.query = "q"
            queued.mode = "standard"
            queued.settings_snapshot = {}

            with pytest.raises(DuplicateResearchError):
                processor._start_research(mock_session, "alice", "pw", queued)

            # Status unchanged — we did not overwrite a terminal state.
            assert mock_research.status == starting_status
            # Spawn never attempted — no risk of a second thread.
            mock_start_research.assert_not_called()
            # No commit for a status we did not change.
            mock_session.commit.assert_not_called()

    @patch(f"{MODULE}.UserActiveResearch")
    @patch(f"{MODULE}.start_research_process")
    def test_user_active_research_commit_failure_raises_duplicate(
        self, mock_start_research, mock_user_active_research
    ):
        """If the post-spawn UserActiveResearch commit fails, the thread
        is already running. We must raise DuplicateResearchError so the
        caller's dup branch deletes the queue row without bumping the
        spawn-retry counter — otherwise a commit failure at the retry
        limit would mark a LIVE thread as terminal FAILED."""
        from local_deep_research.constants import ResearchStatus
        from local_deep_research.exceptions import DuplicateResearchError

        processor = _make_processor()

        mock_thread = Mock()
        mock_thread.ident = 42
        mock_start_research.return_value = mock_thread

        mock_session = MagicMock()
        mock_research = Mock()
        mock_research.status = ResearchStatus.QUEUED
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        # Two commits: one for IN_PROGRESS (succeeds), one for
        # UserActiveResearch (fails).
        commit_results = [None, RuntimeError("UAR commit failed")]

        def _commit():
            r = commit_results.pop(0)
            if isinstance(r, Exception):
                raise r

        mock_session.commit.side_effect = _commit

        queued = Mock()
        queued.research_id = "r-uar-fail"
        queued.query = "q"
        queued.mode = "standard"
        queued.settings_snapshot = {}

        with pytest.raises(DuplicateResearchError):
            processor._start_research(mock_session, "alice", "pw", queued)

        # Spawn *was* called (that's how we get into the post-spawn commit).
        mock_start_research.assert_called_once()
        # Rollback happened after the commit failure.
        assert mock_session.rollback.called
        # Status must remain IN_PROGRESS — the thread is live, resetting
        # would contradict its own writes. This is the key invariant
        # that distinguishes this branch from the generic spawn-failure
        # branch (which DOES reset to QUEUED).
        assert mock_research.status == ResearchStatus.IN_PROGRESS
        # Regression guard: _start_research itself must not touch the
        # spawn-retry counter on the UAR-commit-failure path. The
        # counter is the caller's responsibility (the dup branch in
        # _start_queued_researches clears it); if anyone moves bump
        # logic into _start_research, this assertion catches it and
        # prevents a live thread from being marked terminal FAILED at
        # SPAWN_RETRY_LIMIT.
        assert "r-uar-fail" not in processor._spawn_retry_counts


# ---------------------------------------------------------------------------
# process_pending_operations_for_user — progress/error updates, rollback
# ---------------------------------------------------------------------------


class TestProcessPendingOperationsForUser:
    """Tests for process_pending_operations_for_user."""

    def test_progress_update_sets_progress(self):
        """Progress update operation sets research.progress."""
        processor = _make_processor()

        # Queue a progress update
        processor.queue_progress_update("alice", "r-200", 42.5)

        mock_session = MagicMock()
        mock_research = Mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        count = processor.process_pending_operations_for_user(
            "alice", mock_session
        )

        assert count == 1
        assert mock_research.progress == 42.5
        mock_session.commit.assert_called()

    def test_error_update_sets_all_fields(self):
        """Error update sets status, error_message, metadata, completed_at."""
        processor = _make_processor()

        processor.queue_error_update(
            username="alice",
            research_id="r-201",
            status="failed",
            error_message="OOM killed",
            metadata={"retries": 3},
            completed_at="2026-01-01T00:00:00",
            report_path="/tmp/error.html",
        )

        mock_session = MagicMock()
        mock_research = Mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        count = processor.process_pending_operations_for_user(
            "alice", mock_session
        )

        assert count == 1
        assert mock_research.status == "failed"
        assert mock_research.error_message == "OOM killed"
        assert mock_research.research_meta == {"retries": 3}
        assert mock_research.completed_at == "2026-01-01T00:00:00"
        assert mock_research.report_path == "/tmp/error.html"

    def test_no_operations_returns_zero(self):
        """No pending operations for user -> returns 0."""
        processor = _make_processor()

        mock_session = MagicMock()
        count = processor.process_pending_operations_for_user(
            "alice", mock_session
        )

        assert count == 0

    def test_operations_for_other_users_not_processed(self):
        """Only processes operations for the specified user."""
        processor = _make_processor()

        processor.queue_progress_update("bob", "r-300", 50.0)
        processor.queue_progress_update("alice", "r-301", 75.0)

        mock_session = MagicMock()
        mock_research = Mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        count = processor.process_pending_operations_for_user(
            "alice", mock_session
        )

        assert count == 1
        assert mock_research.progress == 75.0
        # Bob's operation should still be pending
        assert len(processor.pending_operations) == 1

    def test_exception_triggers_rollback(self):
        """Exception during processing triggers session rollback."""
        processor = _make_processor()

        processor.queue_progress_update("alice", "r-400", 50.0)

        mock_session = MagicMock()
        mock_session.query.side_effect = RuntimeError("DB error")

        count = processor.process_pending_operations_for_user(
            "alice", mock_session
        )

        assert count == 0
        mock_session.rollback.assert_called_once()

    def test_error_update_without_report_path(self):
        """Error update without report_path doesn't set report_path."""
        processor = _make_processor()

        processor.queue_error_update(
            username="alice",
            research_id="r-401",
            status="suspended",
            error_message="Timeout",
            metadata={},
            completed_at="2026-01-01",
        )

        mock_session = MagicMock()
        mock_research = Mock(
            spec=[
                "status",
                "error_message",
                "research_meta",
                "completed_at",
                "report_path",
            ]
        )
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        count = processor.process_pending_operations_for_user(
            "alice", mock_session
        )
        assert count == 1
        assert mock_research.status == "suspended"
