"""
Tests for QueueProcessorV2 error recovery, cleanup, concurrency, and
process_user_request — replacing fake tests that never import the real class.

Source: src/local_deep_research/web/queue/processor_v2.py
"""

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

import pytest

from local_deep_research.web.queue.processor_v2 import QueueProcessorV2


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def processor():
    """Create a fresh QueueProcessorV2 instance (not started)."""
    return QueueProcessorV2(check_interval=1)


@contextmanager
def _mock_db_session():
    """Yield a chainable mock db session."""
    mock_session = Mock()
    mock_query = Mock()
    mock_query.filter_by.return_value = mock_query
    mock_query.filter.return_value = mock_query
    mock_query.order_by.return_value = mock_query
    mock_query.limit.return_value = mock_query
    mock_query.first.return_value = None
    mock_query.all.return_value = []
    mock_query.count.return_value = 0
    mock_session.query.return_value = mock_query
    yield mock_session


# ---------------------------------------------------------------------------
# _commit_with_safe_rollback / _delete_queue_row_safely helpers
# ---------------------------------------------------------------------------


class TestCommitWithSafeRollback:
    """The commit-with-safe-rollback helper is the base primitive used
    throughout _start_queued_researches; verify each branch in isolation."""

    def test_commit_success_returns_true_no_rollback(self, processor):
        mock_session = Mock()
        result = processor._commit_with_safe_rollback(mock_session, "ctx")
        assert result is True
        mock_session.commit.assert_called_once()
        mock_session.rollback.assert_not_called()

    def test_commit_failure_returns_false_and_rolls_back(self, processor):
        mock_session = Mock()
        mock_session.commit.side_effect = RuntimeError("commit boom")
        result = processor._commit_with_safe_rollback(mock_session, "ctx")
        assert result is False
        mock_session.commit.assert_called_once()
        mock_session.rollback.assert_called_once()

    def test_commit_and_rollback_both_raise_does_not_propagate(self, processor):
        """If both commit and rollback fail, the helper must NOT
        propagate — the caller has no way to recover and we are already
        in a best-effort cleanup path."""
        mock_session = Mock()
        mock_session.commit.side_effect = RuntimeError("commit boom")
        mock_session.rollback.side_effect = RuntimeError("rollback boom")
        result = processor._commit_with_safe_rollback(mock_session, "ctx")
        assert result is False


class TestDeleteQueueRowSafely:
    """The dup-branch cleanup helper: rollback-first, re-query, delete,
    commit. All failures absorbed — this is a best-effort path."""

    def _session_with_queued_row(self, queued_row):
        """Mock session where the QueuedResearch filter_by().first()
        returns ``queued_row`` (or None)."""
        mock_session = Mock()
        mock_query = Mock()
        mock_query.filter_by.return_value = mock_query
        mock_query.first.return_value = queued_row
        mock_session.query.return_value = mock_query
        return mock_session

    def test_deletes_existing_row_and_commits(self, processor):
        queued_row = Mock()
        mock_session = self._session_with_queued_row(queued_row)

        processor._delete_queue_row_safely(mock_session, "alice", "r-1")

        mock_session.delete.assert_called_once_with(queued_row)
        mock_session.commit.assert_called_once()

    def test_no_row_present_does_not_call_delete_but_still_commits(
        self, processor
    ):
        """If the row was already deleted by a concurrent worker / prior
        retry, the helper must not call ``delete()`` with ``None`` but
        still issue the commit to flush the preceding rollback."""
        mock_session = self._session_with_queued_row(None)

        processor._delete_queue_row_safely(mock_session, "alice", "r-1")

        mock_session.delete.assert_not_called()
        mock_session.commit.assert_called_once()

    def test_initial_rollback_failure_does_not_prevent_delete(self, processor):
        """The pre-emptive rollback is best-effort; if it fails we still
        try the query+delete+commit sequence."""
        queued_row = Mock()
        mock_session = self._session_with_queued_row(queued_row)
        mock_session.rollback.side_effect = RuntimeError("rollback boom")

        processor._delete_queue_row_safely(mock_session, "alice", "r-1")

        mock_session.delete.assert_called_once_with(queued_row)
        mock_session.commit.assert_called_once()

    def test_query_failure_is_absorbed(self, processor):
        """If the re-query itself raises (session truly poisoned), the
        helper must not propagate — the caller has no handler for this."""
        mock_session = Mock()
        mock_session.query.side_effect = RuntimeError("query boom")

        # No exception expected.
        processor._delete_queue_row_safely(mock_session, "alice", "r-1")


# ---------------------------------------------------------------------------
# _start_queued_researches error recovery
# ---------------------------------------------------------------------------


class TestStartQueuedResearchesErrorRecovery:
    """When _start_research throws, verify is_processing reset and task FAILED."""

    def test_error_resets_is_processing(self, processor):
        """On exception, is_processing must be set back to False so the
        next loop tick can retry."""
        queued = Mock()
        queued.is_processing = False
        queued.research_id = "res-1"

        mock_session = Mock()
        mock_query = Mock()
        mock_query.filter_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = [queued]
        # Error handler re-queries the row; return the same object.
        mock_query.first.return_value = queued
        mock_session.query.return_value = mock_query

        mock_queue_service = Mock()

        # _start_research throws
        processor._start_research = Mock(side_effect=RuntimeError("boom"))

        processor._start_queued_researches(
            mock_session, mock_queue_service, "alice", "pw", 1
        )

        assert queued.is_processing is False

    def test_transient_error_leaves_queued_for_retry(self, processor):
        """On a single spawn failure, the queue row stays and is_processing
        is reset, allowing the next loop tick to retry."""
        queued = Mock()
        queued.is_processing = False
        queued.research_id = "res-2"

        mock_session = Mock()
        mock_query = Mock()
        mock_query.filter_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = [queued]
        mock_query.first.return_value = queued
        mock_session.query.return_value = mock_query

        mock_queue_service = Mock()
        processor._start_research = Mock(side_effect=RuntimeError("boom"))

        processor._start_queued_researches(
            mock_session, mock_queue_service, "alice", "pw", 1
        )

        # Row must remain queued for retry — not deleted, not marked FAILED.
        mock_session.delete.assert_not_called()
        assert queued.is_processing is False
        assert processor._spawn_retry_counts["res-2"] == 1

    def test_terminal_failure_after_retry_limit(self, processor):
        """After SPAWN_RETRY_LIMIT consecutive failures, row is deleted,
        ResearchHistory.status is set to FAILED, and notify_research_failed
        is invoked exactly once."""
        from local_deep_research.constants import ResearchStatus
        from local_deep_research.database.models import (
            QueuedResearch,
            ResearchHistory,
        )
        from local_deep_research.web.queue.processor_v2 import (
            SPAWN_RETRY_LIMIT,
        )

        queued = Mock()
        queued.is_processing = False
        queued.research_id = "res-3"

        research_row = Mock()

        # Route queries by model class: QueuedResearch re-query returns
        # the queued row; ResearchHistory lookup returns research_row.
        def _make_query(model):
            q = Mock()
            q.filter_by.return_value = q
            q.order_by.return_value = q
            q.limit.return_value = q
            if model is QueuedResearch:
                q.all.return_value = [queued]
                q.first.return_value = queued
            elif model is ResearchHistory:
                q.first.return_value = research_row
            else:
                q.all.return_value = []
                q.first.return_value = None
            return q

        mock_session = Mock()
        mock_session.query.side_effect = _make_query

        mock_queue_service = Mock()
        processor._start_research = Mock(side_effect=RuntimeError("boom"))
        processor.notify_research_failed = Mock()

        # Simulate SPAWN_RETRY_LIMIT loop iterations: each call processes
        # the same queued row and fails.
        for _ in range(SPAWN_RETRY_LIMIT):
            processor._start_queued_researches(
                mock_session, mock_queue_service, "alice", "pw", 1
            )

        # After exhausting attempts: terminal path triggered once.
        assert "res-3" not in processor._spawn_retry_counts
        assert processor.notify_research_failed.call_count == 1
        call_args = processor.notify_research_failed.call_args
        assert call_args.kwargs["username"] == "alice"
        assert call_args.kwargs["research_id"] == "res-3"
        # Queue row deleted and ResearchHistory marked FAILED.
        mock_session.delete.assert_called_with(queued)
        assert research_row.status == ResearchStatus.FAILED

    def test_capacity_reject_does_not_count_toward_retry_limit(self, processor):
        """A SystemAtCapacityError re-raised from _start_research is a
        transient condition (system globally at capacity), NOT a spawn
        failure. It must NOT bump the spawn-retry counter or mark the
        research FAILED — otherwise a busy system destroys perfectly valid
        queued work after a few ticks. Regression for the missing dedicated
        `except SystemAtCapacityError` clause in _start_queued_researches."""
        from local_deep_research.constants import ResearchStatus
        from local_deep_research.database.models import (
            QueuedResearch,
            ResearchHistory,
        )
        from local_deep_research.exceptions import SystemAtCapacityError
        from local_deep_research.web.queue.processor_v2 import (
            SPAWN_RETRY_LIMIT,
        )

        queued = Mock()
        queued.is_processing = False
        queued.research_id = "res-cap"

        research_row = Mock()

        def _make_query(model):
            q = Mock()
            q.filter_by.return_value = q
            q.order_by.return_value = q
            q.limit.return_value = q
            if model is QueuedResearch:
                q.all.return_value = [queued]
                q.first.return_value = queued
            elif model is ResearchHistory:
                q.first.return_value = research_row
            else:
                q.all.return_value = []
                q.first.return_value = None
            return q

        mock_session = Mock()
        mock_session.query.side_effect = _make_query

        mock_queue_service = Mock()
        processor._start_research = Mock(
            side_effect=SystemAtCapacityError("at capacity")
        )
        processor.notify_research_failed = Mock()

        # Run well past SPAWN_RETRY_LIMIT — capacity rejection must never
        # accumulate toward the limit no matter how many ticks pass.
        for _ in range(SPAWN_RETRY_LIMIT + 2):
            processor._start_queued_researches(
                mock_session, mock_queue_service, "alice", "pw", 1
            )

        # Never counted toward the retry limit, never marked FAILED, never
        # deleted, never notified — left QUEUED (is_processing reset) so the
        # next tick can retry once capacity frees up.
        assert "res-cap" not in processor._spawn_retry_counts
        processor.notify_research_failed.assert_not_called()
        mock_session.delete.assert_not_called()
        assert research_row.status != ResearchStatus.FAILED
        assert queued.is_processing is False

    def test_counter_resets_on_successful_start(self, processor):
        """If a research eventually succeeds after prior failures, the
        retry counter entry is popped so a future fresh failure gets a
        full retry budget."""
        queued = Mock()
        queued.is_processing = False
        queued.research_id = "res-4"

        mock_session = Mock()
        mock_query = Mock()
        mock_query.filter_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = [queued]
        mock_query.first.return_value = queued
        mock_session.query.return_value = mock_query

        mock_queue_service = Mock()
        processor._spawn_retry_counts["res-4"] = 2  # prior failures
        processor._start_research = Mock()  # success this time

        processor._start_queued_researches(
            mock_session, mock_queue_service, "alice", "pw", 1
        )

        assert "res-4" not in processor._spawn_retry_counts
        mock_session.delete.assert_called_with(queued)

    def test_success_deletes_queued_record(self, processor):
        """On success, the queued_research is deleted from DB."""
        queued = Mock()
        queued.is_processing = False
        queued.research_id = "res-3"

        mock_session = Mock()
        mock_query = Mock()
        mock_query.filter_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = [queued]
        mock_session.query.return_value = mock_query

        mock_queue_service = Mock()
        processor._start_research = Mock()  # success

        processor._start_queued_researches(
            mock_session, mock_queue_service, "alice", "pw", 1
        )

        mock_session.delete.assert_called_once_with(queued)

    def test_respects_available_slots_limit(self, processor):
        """The .limit() call should use available_slots."""
        mock_session = Mock()
        mock_query = Mock()
        mock_query.filter_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = []
        mock_session.query.return_value = mock_query

        mock_queue_service = Mock()

        processor._start_queued_researches(
            mock_session, mock_queue_service, "alice", "pw", 5
        )

        mock_query.limit.assert_called_with(5)

    def test_skip_when_already_claimed_by_another_worker(self, processor):
        """When the atomic UPDATE matches zero rows (another worker
        already flipped is_processing to True since our SELECT), we must
        skip the item without starting or deleting it."""
        queued = Mock()
        queued.is_processing = False
        queued.research_id = "res-claimed"

        mock_session = Mock()
        mock_query = Mock()
        mock_query.filter_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = [queued]
        # Simulate lost claim race: another worker beat us to it.
        mock_query.update.return_value = 0
        mock_session.query.return_value = mock_query

        mock_queue_service = Mock()
        processor._start_research = Mock()

        processor._start_queued_researches(
            mock_session, mock_queue_service, "alice", "pw", 1
        )

        processor._start_research.assert_not_called()
        mock_session.delete.assert_not_called()
        mock_session.refresh.assert_not_called()
        mock_queue_service.update_task_status.assert_not_called()

    def test_duplicate_research_error_deletes_queue_row_and_does_not_retry(
        self, processor
    ):
        """DuplicateResearchError means a live thread already exists (prior
        attempt's post-spawn commit failed). The queue row must be deleted,
        the retry counter cleared, and the failure-notification path must
        NOT run — that would terminate-status a research that is actually
        running."""
        from local_deep_research.exceptions import DuplicateResearchError

        queued = Mock()
        queued.is_processing = False
        queued.research_id = "res-dup"

        mock_session = Mock()
        mock_query = Mock()
        mock_query.filter_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = [queued]
        # The dup-cleanup path re-queries the queued row before deleting.
        mock_query.first.return_value = queued
        mock_session.query.return_value = mock_query

        mock_queue_service = Mock()
        processor._start_research = Mock(
            side_effect=DuplicateResearchError("thread already live")
        )
        processor.notify_research_failed = Mock()

        processor._start_queued_researches(
            mock_session, mock_queue_service, "alice", "pw", 1
        )

        # Queue row deleted, counter cleared, no failure notification.
        mock_session.delete.assert_called_with(queued)
        assert "res-dup" not in processor._spawn_retry_counts
        processor.notify_research_failed.assert_not_called()

    def test_duplicate_research_error_does_not_mark_failed_at_retry_limit(
        self, processor
    ):
        """Even when the retry counter is already at SPAWN_RETRY_LIMIT - 1
        (i.e. one more bump would trigger the terminal FAILED path),
        DuplicateResearchError must NOT increment the counter or mark
        ResearchHistory.status = FAILED. A live thread is running and
        will write its own terminal status."""
        from local_deep_research.database.models import (
            QueuedResearch,
            ResearchHistory,
        )
        from local_deep_research.exceptions import DuplicateResearchError
        from local_deep_research.web.queue.processor_v2 import (
            SPAWN_RETRY_LIMIT,
        )

        queued = Mock()
        queued.is_processing = False
        queued.research_id = "res-dup-limit"

        research_row = Mock()
        # Distinct status sentinel we can check was not overwritten.
        research_row.status = "NOT-FAILED-SENTINEL"

        def _make_query(model):
            q = Mock()
            q.filter_by.return_value = q
            q.order_by.return_value = q
            q.limit.return_value = q
            if model is QueuedResearch:
                q.all.return_value = [queued]
                q.first.return_value = queued
            elif model is ResearchHistory:
                q.first.return_value = research_row
            else:
                q.all.return_value = []
                q.first.return_value = None
            return q

        mock_session = Mock()
        mock_session.query.side_effect = _make_query

        mock_queue_service = Mock()
        processor._spawn_retry_counts["res-dup-limit"] = SPAWN_RETRY_LIMIT - 1
        processor._start_research = Mock(
            side_effect=DuplicateResearchError("thread already live")
        )
        processor.notify_research_failed = Mock()

        processor._start_queued_researches(
            mock_session, mock_queue_service, "alice", "pw", 1
        )

        # Counter cleared, not incremented to SPAWN_RETRY_LIMIT.
        assert "res-dup-limit" not in processor._spawn_retry_counts
        # ResearchHistory.status untouched — live thread owns it.
        assert research_row.status == "NOT-FAILED-SENTINEL"
        # No failure notification sent to the user.
        processor.notify_research_failed.assert_not_called()

    def test_transient_failure_then_duplicate_recovers_cleanly(self, processor):
        """End-to-end of the CRITICAL scenario this PR fixes: first tick's
        spawn succeeds but the post-spawn commit fails (simulated as a
        RuntimeError from _start_research); second tick finds the live
        thread and gets DuplicateResearchError. The dup branch must
        delete the queue row, clear the counter, and NOT mark FAILED —
        exactly what the original retry loop failed to do."""
        from local_deep_research.database.models import (
            QueuedResearch,
            ResearchHistory,
        )
        from local_deep_research.exceptions import DuplicateResearchError

        queued = Mock()
        queued.is_processing = False
        queued.research_id = "res-recover"

        research_row = Mock()
        # Sentinel so we can prove status was never overwritten to FAILED.
        research_row.status = "LIVE-THREAD-OWNED-SENTINEL"

        def _make_query(model):
            q = Mock()
            q.filter_by.return_value = q
            q.order_by.return_value = q
            q.limit.return_value = q
            if model is QueuedResearch:
                q.all.return_value = [queued]
                q.first.return_value = queued
            elif model is ResearchHistory:
                q.first.return_value = research_row
            else:
                q.all.return_value = []
                q.first.return_value = None
            return q

        mock_session = Mock()
        mock_session.query.side_effect = _make_query

        mock_queue_service = Mock()
        processor.notify_research_failed = Mock()

        # Tick 1: simulate post-spawn commit failure — _start_research
        # raised after the thread was already live. The generic except
        # path bumps the counter and leaves the row for retry.
        processor._start_research = Mock(
            side_effect=RuntimeError("post-spawn commit failed")
        )
        processor._start_queued_researches(
            mock_session, mock_queue_service, "alice", "pw", 1
        )
        assert processor._spawn_retry_counts["res-recover"] == 1
        processor.notify_research_failed.assert_not_called()

        # Tick 2: the live thread from tick 1 is still running, so
        # start_research_process raises DuplicateResearchError.
        processor._start_research = Mock(
            side_effect=DuplicateResearchError("thread already live")
        )
        processor._start_queued_researches(
            mock_session, mock_queue_service, "alice", "pw", 1
        )

        # Dup branch: counter cleared, queue row deleted, FAILED never
        # written, user never notified of a failure that didn't happen.
        assert "res-recover" not in processor._spawn_retry_counts
        assert research_row.status == "LIVE-THREAD-OWNED-SENTINEL"
        processor.notify_research_failed.assert_not_called()
        # Queue row was deleted (any call with the queued row counts).
        assert any(
            call.args and call.args[0] is queued
            for call in mock_session.delete.call_args_list
        )


# ---------------------------------------------------------------------------
# _reclaim_stranded_queue_rows — crash/restart recovery
# ---------------------------------------------------------------------------


class TestReclaimStrandedQueueRows:
    """Verify the reclaim pass recovers queue rows stranded by a crash
    between pre-spawn commit and queue-row deletion."""

    def _make_session_with_stranded_rows(self, stranded_rows, research_rows):
        """Build a mock session that returns ``stranded_rows`` for the
        QueuedResearch is_processing=True query and per-id lookups for
        ResearchHistory."""
        from local_deep_research.database.models import (
            QueuedResearch,
            ResearchHistory,
        )

        def _make_query(model):
            q = Mock()
            q.filter_by.return_value = q
            q.order_by.return_value = q
            q.limit.return_value = q
            if model is QueuedResearch:
                q.all.return_value = stranded_rows
                q.first.return_value = None
            elif model is ResearchHistory:

                def _first():
                    last_filter = q.filter_by.call_args
                    rid = last_filter.kwargs.get("id") if last_filter else None
                    return research_rows.get(rid)

                q.first.side_effect = _first
            else:
                q.all.return_value = []
                q.first.return_value = None
            return q

        session = Mock()
        session.query.side_effect = _make_query
        return session

    def test_reclaims_row_with_no_live_thread_and_in_progress_status(
        self, processor
    ):
        """After a crash, a row with is_processing=True + status=IN_PROGRESS
        + no live thread must be reverted to is_processing=False +
        status=QUEUED."""
        from local_deep_research.constants import ResearchStatus

        stranded = Mock()
        stranded.research_id = "res-stranded"
        stranded.is_processing = True

        research_row = Mock()
        research_row.status = ResearchStatus.IN_PROGRESS

        mock_session = self._make_session_with_stranded_rows(
            [stranded], {"res-stranded": research_row}
        )

        with patch(
            "local_deep_research.web.routes.globals.is_research_active",
            return_value=False,
        ):
            reclaimed = processor._reclaim_stranded_queue_rows(
                mock_session, "alice"
            )

        assert reclaimed == 1
        assert stranded.is_processing is False
        assert research_row.status == ResearchStatus.QUEUED
        assert mock_session.commit.called

    def test_does_not_reclaim_row_with_live_thread(self, processor):
        """A row whose research_id is in _active_research (live thread)
        must NOT be reclaimed — it's legitimately in-flight."""
        from local_deep_research.constants import ResearchStatus

        stranded = Mock()
        stranded.research_id = "res-live"
        stranded.is_processing = True

        research_row = Mock()
        research_row.status = ResearchStatus.IN_PROGRESS

        mock_session = self._make_session_with_stranded_rows(
            [stranded], {"res-live": research_row}
        )

        with patch(
            "local_deep_research.web.routes.globals.is_research_active",
            return_value=True,
        ):
            reclaimed = processor._reclaim_stranded_queue_rows(
                mock_session, "alice"
            )

        assert reclaimed == 0
        assert stranded.is_processing is True
        assert research_row.status == ResearchStatus.IN_PROGRESS
        # No commit needed when nothing was reclaimed.
        assert not mock_session.commit.called

    def test_reclaims_row_without_touching_terminal_status(self, processor):
        """If a stranded row's ResearchHistory is already COMPLETED/FAILED
        (thread finished cleanly before the crash), only reset
        is_processing; do NOT overwrite the terminal status."""
        from local_deep_research.constants import ResearchStatus

        stranded = Mock()
        stranded.research_id = "res-done"
        stranded.is_processing = True

        research_row = Mock()
        research_row.status = ResearchStatus.COMPLETED

        mock_session = self._make_session_with_stranded_rows(
            [stranded], {"res-done": research_row}
        )

        with patch(
            "local_deep_research.web.routes.globals.is_research_active",
            return_value=False,
        ):
            reclaimed = processor._reclaim_stranded_queue_rows(
                mock_session, "alice"
            )

        assert reclaimed == 1
        assert stranded.is_processing is False
        # Terminal status untouched.
        assert research_row.status == ResearchStatus.COMPLETED

    def test_no_stranded_rows_is_cheap_noop(self, processor):
        """With zero stranded rows, no commit should fire."""
        mock_session = self._make_session_with_stranded_rows([], {})

        with patch(
            "local_deep_research.web.routes.globals.is_research_active",
            return_value=False,
        ):
            reclaimed = processor._reclaim_stranded_queue_rows(
                mock_session, "alice"
            )

        assert reclaimed == 0
        assert not mock_session.commit.called

    def test_start_queued_researches_calls_reclaim_before_select(
        self, processor
    ):
        """_start_queued_researches must invoke _reclaim_stranded_queue_rows
        BEFORE the main QueuedResearch SELECT. If the order were swapped,
        rows just reclaimed (is_processing flipped to False) would still
        be invisible to the SELECT in the same tick — which is the entire
        point of the feature. Use a shared ``call_order`` list to assert
        the ordering, not just that both calls happened."""
        call_order = []

        mock_session = Mock()
        mock_query = Mock()
        mock_query.filter_by.return_value = mock_query
        mock_query.order_by.return_value = mock_query
        mock_query.limit.return_value = mock_query
        mock_query.all.return_value = []

        def _query_side_effect(*args, **kwargs):
            call_order.append("query")
            return mock_query

        mock_session.query.side_effect = _query_side_effect

        def _reclaim_side_effect(*args, **kwargs):
            call_order.append("reclaim")
            return 0

        mock_queue_service = Mock()
        processor._reclaim_stranded_queue_rows = Mock(
            side_effect=_reclaim_side_effect
        )

        processor._start_queued_researches(
            mock_session, mock_queue_service, "alice", "pw", 1
        )

        processor._reclaim_stranded_queue_rows.assert_called_once_with(
            mock_session, "alice"
        )
        # Ordering invariant: reclaim must land before the first query.
        assert call_order[0] == "reclaim", (
            f"Expected 'reclaim' first, got order: {call_order}"
        )


# ---------------------------------------------------------------------------
# _start_research_directly cleanup
# ---------------------------------------------------------------------------


class TestStartResearchDirectlyCleanup:
    """When start_research_process throws, verify UserActiveResearch deleted."""

    @patch("local_deep_research.web.queue.processor_v2.UserQueueService")
    @patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
    @patch("local_deep_research.web.queue.processor_v2.start_research_process")
    def test_cleanup_on_start_failure(
        self, mock_start, mock_get_session, mock_qs, processor
    ):
        """When start_research_process raises, the active record must be
        deleted AND the ResearchHistory row marked FAILED."""
        from local_deep_research.constants import ResearchStatus
        from local_deep_research.database.models import (
            ResearchHistory,
            UserActiveResearch,
        )

        active_record = Mock()
        research_row = Mock()

        session1 = Mock()  # Create record session
        session2 = Mock()  # Cleanup session

        def _query(model):
            q = Mock()
            q.filter_by.return_value = q
            q.filter.return_value = q
            if model is UserActiveResearch:
                q.first.return_value = active_record
            elif model is ResearchHistory:
                q.first.return_value = research_row
            else:
                q.first.return_value = None
            return q

        session2.query.side_effect = _query

        sessions = [session1, session2]
        call_count = [0]

        @contextmanager
        def fake_session(*args, **kwargs):
            idx = min(call_count[0], len(sessions) - 1)
            call_count[0] += 1
            yield sessions[idx]

        mock_get_session.side_effect = fake_session
        mock_start.side_effect = RuntimeError("process failed")

        processor._start_research_directly(
            "alice",
            "res-1",
            "password",
            query="test",
            mode="quick",
        )

        # Cleanup session deletes the active record AND marks FAILED.
        session2.delete.assert_called_once_with(active_record)
        assert research_row.status == ResearchStatus.FAILED
        session2.commit.assert_called()

    @patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
    @patch("local_deep_research.web.queue.processor_v2.start_research_process")
    def test_success_updates_thread_id(
        self, mock_start, mock_get_session, processor
    ):
        """On success, thread ID is updated in active record."""
        mock_thread = Mock()
        mock_thread.ident = 12345
        mock_start.return_value = mock_thread

        active_record = Mock()
        mock_session = Mock()
        mock_query = Mock()
        mock_query.filter_by.return_value = mock_query
        mock_query.first.return_value = active_record
        mock_session.query.return_value = mock_query

        @contextmanager
        def fake_session(*args, **kwargs):
            yield mock_session

        mock_get_session.side_effect = fake_session

        processor._start_research_directly(
            "alice",
            "res-1",
            "password",
            query="test",
            mode="quick",
        )

        assert active_record.thread_id == "12345"


# ---------------------------------------------------------------------------
# process_user_request
# ---------------------------------------------------------------------------


class TestProcessUserRequest:
    """Tests for process_user_request return values."""

    @patch("local_deep_research.web.queue.processor_v2.db_manager")
    @patch("local_deep_research.web.queue.processor_v2.session_password_store")
    @patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
    def test_no_password_returns_zero(
        self, mock_session, mock_pw_store, mock_db, processor
    ):
        """When no password available, returns 0 (line 686)."""
        mock_pw_store.get_session_password.return_value = None

        result = processor.process_user_request("alice", "sess-1")
        assert result == 0

    @patch("local_deep_research.web.queue.processor_v2.db_manager")
    @patch("local_deep_research.web.queue.processor_v2.session_password_store")
    @patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
    def test_exception_returns_zero(
        self, mock_session, mock_pw_store, mock_db, processor
    ):
        """When exception occurs, returns 0 (line 690)."""
        mock_pw_store.get_session_password.return_value = "pw"
        mock_db.open_user_database.side_effect = RuntimeError("db error")

        result = processor.process_user_request("alice", "sess-1")
        assert result == 0

    @patch("local_deep_research.web.queue.processor_v2.db_manager")
    @patch("local_deep_research.web.queue.processor_v2.session_password_store")
    @patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
    def test_returns_queued_count(
        self, mock_get_session, mock_pw_store, mock_db, processor
    ):
        """When queue has items, returns queued count (line 684)."""
        mock_pw_store.get_session_password.return_value = "pw"
        mock_db.open_user_database.return_value = Mock()

        mock_queue_service = Mock()
        mock_queue_service.get_queue_status.return_value = {"queued_tasks": 3}

        mock_session = Mock()

        @contextmanager
        def fake_session(*args, **kwargs):
            yield mock_session

        mock_get_session.side_effect = fake_session

        with patch(
            "local_deep_research.web.queue.processor_v2.UserQueueService",
            return_value=mock_queue_service,
        ):
            result = processor.process_user_request("alice", "sess-1")

        assert result == 3

    @patch("local_deep_research.web.queue.processor_v2.db_manager")
    @patch("local_deep_research.web.queue.processor_v2.session_password_store")
    @patch("local_deep_research.web.queue.processor_v2.get_user_db_session")
    def test_empty_queue_returns_zero(
        self, mock_get_session, mock_pw_store, mock_db, processor
    ):
        """When queue is empty, returns 0."""
        mock_pw_store.get_session_password.return_value = "pw"
        mock_db.open_user_database.return_value = Mock()

        mock_queue_service = Mock()
        mock_queue_service.get_queue_status.return_value = {"queued_tasks": 0}

        mock_session = Mock()

        @contextmanager
        def fake_session(*args, **kwargs):
            yield mock_session

        mock_get_session.side_effect = fake_session

        with patch(
            "local_deep_research.web.queue.processor_v2.UserQueueService",
            return_value=mock_queue_service,
        ):
            result = processor.process_user_request("alice", "sess-1")

        assert result == 0


# ---------------------------------------------------------------------------
# notify_user_activity concurrency
# ---------------------------------------------------------------------------


class TestNotifyUserActivityConcurrency:
    """Concurrent notify_user_activity calls from 20 threads."""

    def test_concurrent_notify_all_users_registered(self, processor):
        """All users registered via concurrent notify_user_activity calls."""
        barrier = threading.Barrier(20)
        errors = []

        def worker(i):
            try:
                barrier.wait(timeout=5)
                processor.notify_user_activity(f"user-{i}", f"sess-{i}")
            except Exception as e:
                errors.append(e)

        threads = [
            threading.Thread(target=worker, args=(i,)) for i in range(20)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=10)

        assert not errors
        assert len(processor._users_to_check) == 20
        for i in range(20):
            assert (f"user-{i}", f"sess-{i}") in processor._users_to_check

    def test_duplicate_user_session_deduplicated(self, processor):
        """Same user:session added multiple times is stored once (set behavior)."""
        for _ in range(5):
            processor.notify_user_activity("alice", "sess-1")

        assert len(processor._users_to_check) == 1
        assert ("alice", "sess-1") in processor._users_to_check


# ---------------------------------------------------------------------------
# _spawn_retry_counts concurrent access
# ---------------------------------------------------------------------------


class TestSpawnRetryCountsConcurrency:
    """Verify _spawn_retry_counts increments are atomic under contention."""

    def test_concurrent_increments_do_not_lose_updates(self, processor):
        """Calls the production ``_bump_spawn_retry_count`` helper from
        many threads. Without the lock inside that helper, the
        read-modify-write loses increments under contention and the
        final count is less than ``n_threads``. With the lock, the final
        count must equal ``n_threads``. This is a real mutation test of
        the production lock — removing ``with self._spawn_retry_counts_lock:``
        from the helper makes this test fail."""
        n_threads = 50
        research_id = "contended-id"
        barrier = threading.Barrier(n_threads)
        returned_values = []
        returned_lock = threading.Lock()

        def worker():
            barrier.wait(timeout=5)
            # Call the production increment path, not the lock directly.
            attempts = processor._bump_spawn_retry_count(research_id)
            with returned_lock:
                returned_values.append(attempts)

        threads = [threading.Thread(target=worker) for _ in range(n_threads)]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=10)

        # Final stored count must equal the number of incrementers.
        assert processor._spawn_retry_counts[research_id] == n_threads
        # Returned values must be 1..n_threads with no duplicates — any
        # duplicate would mean two callers observed the same prior count,
        # i.e. a lost update.
        assert sorted(returned_values) == list(range(1, n_threads + 1))


# ---------------------------------------------------------------------------
# Start/stop lifecycle
# ---------------------------------------------------------------------------


class TestProcessorLifecycle:
    """Basic start/stop behavior."""

    def test_start_sets_running(self, processor):
        processor.start()
        assert processor.running is True
        processor.stop()
        assert processor.running is False

    def test_double_start_is_noop(self, processor):
        processor.start()
        processor.start()  # should not raise
        processor.stop()

    def test_stop_without_start(self, processor):
        """Stopping without starting should not raise."""
        processor.stop()
        assert processor.running is False
