"""
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
from local_deep_research.constants import ResearchStatus


# ---------------------------------------------------------------------------
# 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


# ---------------------------------------------------------------------------
# _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 (line 568)."""
        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]
        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_error_sets_task_status_failed(self, processor):
        """On exception, task status must be set to FAILED (line 574)."""
        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_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
        )

        mock_queue_service.update_task_status.assert_any_call(
            "res-2",
            ResearchStatus.FAILED,
            error_message="Failed to start research",
        )

    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)


# ---------------------------------------------------------------------------
# _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, active record is deleted (line 305)."""
        # Session 1: create record (success)
        # Session 2: cleanup after start_research_process failure — query, find, delete
        active_record = Mock()

        session1 = Mock()  # Create record session
        session2 = Mock()  # Cleanup session
        q2 = Mock()
        q2.filter_by.return_value = q2
        q2.first.return_value = active_record
        session2.query.return_value = q2

        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",
        )

        # The cleanup session should delete the active record
        session2.delete.assert_called_once_with(active_record)

    @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


# ---------------------------------------------------------------------------
# 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
