"""Unit tests for Socket.IO event handlers in the chat feature.

These tests verify the SocketIOService event handling for:
- Client connection/disconnection lifecycle
- Subscription management
- Event emission to subscribers
- Error handling and cleanup
"""

from threading import Thread
from unittest.mock import MagicMock, patch

import pytest

from src.local_deep_research.web.services.socket_service import SocketIOService


@pytest.fixture(autouse=True)
def _reset_socketio_singleton():
    """Reset ``SocketIOService._instance`` around every test.

    Each test wired this inline at top + bottom of the method body with
    no ``try/finally`` — so an assertion failure mid-test would skip the
    trailing cleanup and contaminate the next test with a stale
    singleton. An autouse fixture guarantees the reset runs in both
    teardown and setup regardless of test outcome.
    """
    SocketIOService._instance = None
    yield
    SocketIOService._instance = None


class TestSocketIOHandlers:
    """Tests for SocketIOService event handlers."""

    def test_on_connect_logs_client_sid(self, app):
        """Test that client connection is logged with SID."""
        from src.local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        with patch(
            "src.local_deep_research.web.services.socket_service.SocketIO"
        ) as mock_socketio_class:
            mock_socketio = MagicMock()
            mock_socketio_class.return_value = mock_socketio

            with patch(
                "src.local_deep_research.web.services.socket_service.logger"
            ) as mock_logger:
                service = SocketIOService(app=app)

                # Capture the on_connect handler
                connect_handler = None
                for call in mock_socketio.on.call_args_list:
                    if call[0][0] == "connect":
                        connect_handler = (
                            call[0][1] if len(call[0]) > 1 else None
                        )
                        break

                # If using decorator syntax, find it differently
                if connect_handler is None:
                    # The handlers are registered via decorators, so we need to
                    # invoke the internal method directly. The handler reads
                    # flask.session, so a request context is required.
                    mock_request = MagicMock()
                    mock_request.sid = "test-client-123"

                    with app.test_request_context():
                        service._SocketIOService__handle_connect(mock_request)

                    # Verify logging was called
                    assert mock_logger.info.called

    def test_on_disconnect_cleans_up_subscriptions(self):
        """Test that client disconnection cleans up subscriptions."""
        from src.local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        mock_app = MagicMock()
        mock_app.config = {"SECRET_KEY": "test-secret"}

        with patch(
            "src.local_deep_research.web.services.socket_service.SocketIO"
        ) as mock_socketio_class:
            mock_socketio = MagicMock()
            mock_socketio_class.return_value = mock_socketio

            service = SocketIOService(app=mock_app)

            # Manually add a subscription to test cleanup
            mock_request = MagicMock()
            mock_request.sid = "client-to-disconnect"

            # Add subscription via the internal dict (research_id -> set of sids)
            service._SocketIOService__socket_subscriptions["research-1"] = {
                mock_request.sid
            }

            # Call disconnect handler
            service._SocketIOService__handle_disconnect(
                mock_request, "transport close"
            )

            # Verify subscription was cleaned up (research_id entry removed
            # because its only subscriber disconnected)
            assert (
                "research-1"
                not in service._SocketIOService__socket_subscriptions
            )

    def test_on_subscribe_adds_to_subscription_set(self):
        """Test that subscribe event adds client to subscription set."""
        from src.local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        mock_app = MagicMock()
        mock_app.config = {"SECRET_KEY": "test-secret"}

        with (
            patch(
                "src.local_deep_research.web.services.socket_service.SocketIO"
            ) as mock_socketio_class,
            patch(
                "src.local_deep_research.web.services.socket_service.session",
                {"username": "alice"},
            ),
            patch.object(
                SocketIOService, "_user_owns_research", return_value=True
            ),
        ):
            mock_socketio = MagicMock()
            mock_socketio_class.return_value = mock_socketio

            service = SocketIOService(app=mock_app)

            mock_request = MagicMock()
            mock_request.sid = "subscribing-client"

            data = {"research_id": "research-abc-123"}

            # Call subscribe handler
            service._SocketIOService__handle_subscribe(data, mock_request)

            # Verify subscription was added
            subs = service._SocketIOService__socket_subscriptions
            assert "research-abc-123" in subs
            assert "subscribing-client" in subs["research-abc-123"]

    def test_on_subscribe_sends_current_status_if_available(self):
        """Test that subscribe sends current status when research is active."""
        from src.local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        mock_app = MagicMock()
        mock_app.config = {"SECRET_KEY": "test-secret"}

        with (
            patch(
                "src.local_deep_research.web.services.socket_service.SocketIO"
            ) as mock_socketio_class,
            patch(
                "src.local_deep_research.web.services.socket_service.session",
                {"username": "alice"},
            ),
            patch.object(
                SocketIOService, "_user_owns_research", return_value=True
            ),
        ):
            mock_socketio = MagicMock()
            mock_socketio_class.return_value = mock_socketio

            with patch(
                "src.local_deep_research.web.services.socket_service.get_active_research_snapshot"
            ) as mock_snapshot:
                mock_snapshot.return_value = {
                    "progress": 50,
                    "log": [{"message": "Processing...", "time": "2024-01-01"}],
                }

                service = SocketIOService(app=mock_app)

                mock_request = MagicMock()
                mock_request.sid = "subscriber-client"

                data = {"research_id": "active-research-1"}

                # Call subscribe handler
                service._SocketIOService__handle_subscribe(data, mock_request)

                # Verify emit was called with current status
                mock_socketio.emit.assert_called()

    def test_emit_to_subscribers_broadcasts_to_room(self):
        """Test that emit_to_subscribers broadcasts to all subscribers."""
        from src.local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        mock_app = MagicMock()
        mock_app.config = {"SECRET_KEY": "test-secret"}

        with patch(
            "src.local_deep_research.web.services.socket_service.SocketIO"
        ) as mock_socketio_class:
            mock_socketio = MagicMock()
            mock_socketio_class.return_value = mock_socketio

            service = SocketIOService(app=mock_app)

            # Add some subscribers
            service._SocketIOService__socket_subscriptions["research-123"] = {
                "client-1",
                "client-2",
            }

            # Emit to subscribers
            result = service.emit_to_subscribers(
                "research_progress",
                "research-123",
                {"progress": 75, "message": "Almost done"},
            )

            # Verify result
            assert result is True

            # Verify emit was called
            assert mock_socketio.emit.called

    def test_emit_socket_event_handles_exceptions(self):
        """Test that emit_socket_event handles exceptions gracefully."""
        from src.local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        mock_app = MagicMock()
        mock_app.config = {"SECRET_KEY": "test-secret"}

        with patch(
            "src.local_deep_research.web.services.socket_service.SocketIO"
        ) as mock_socketio_class:
            mock_socketio = MagicMock()
            mock_socketio.emit.side_effect = Exception("Network error")
            mock_socketio_class.return_value = mock_socketio

            service = SocketIOService(app=mock_app)

            # Emit should return False on error, not raise
            result = service.emit_socket_event("test_event", {"data": "test"})

            assert result is False

    def test_concurrent_subscribe_unsubscribe_thread_safety(self):
        """Test that concurrent subscribe/unsubscribe operations are thread-safe."""
        from src.local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        mock_app = MagicMock()
        mock_app.config = {"SECRET_KEY": "test-secret"}

        with (
            patch(
                "src.local_deep_research.web.services.socket_service.SocketIO"
            ) as mock_socketio_class,
            patch(
                "src.local_deep_research.web.services.socket_service.session",
                {"username": "alice"},
            ),
            patch.object(
                SocketIOService, "_user_owns_research", return_value=True
            ),
        ):
            mock_socketio = MagicMock()
            mock_socketio_class.return_value = mock_socketio

            service = SocketIOService(app=mock_app)

            errors = []
            research_id = "concurrent-research"

            def subscribe_client(client_id):
                try:
                    mock_request = MagicMock()
                    mock_request.sid = f"client-{client_id}"
                    service._SocketIOService__handle_subscribe(
                        {"research_id": research_id}, mock_request
                    )
                except Exception as e:
                    errors.append(str(e))

            def disconnect_client(client_id):
                try:
                    mock_request = MagicMock()
                    mock_request.sid = f"client-{client_id}"
                    service._SocketIOService__handle_disconnect(
                        mock_request, "close"
                    )
                except Exception as e:
                    errors.append(str(e))

            # Run concurrent operations
            threads = []
            for i in range(10):
                t1 = Thread(target=subscribe_client, args=(i,))
                t2 = Thread(target=disconnect_client, args=(i,))
                threads.extend([t1, t2])

            for t in threads:
                t.start()

            for t in threads:
                t.join()

            # Should complete without errors
            assert len(errors) == 0, f"Concurrent operations failed: {errors}"

    def test_subscription_cleanup_on_error(self):
        """Test that subscriptions are cleaned up even if emit fails."""
        from src.local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        mock_app = MagicMock()
        mock_app.config = {"SECRET_KEY": "test-secret"}

        with (
            patch(
                "src.local_deep_research.web.services.socket_service.SocketIO"
            ) as mock_socketio_class,
            patch(
                "src.local_deep_research.web.services.socket_service.session",
                {"username": "alice"},
            ),
            patch.object(
                SocketIOService, "_user_owns_research", return_value=True
            ),
        ):
            mock_socketio = MagicMock()
            # Make emit fail for specific clients
            mock_socketio.emit.side_effect = Exception("Emit failed")
            mock_socketio_class.return_value = mock_socketio

            service = SocketIOService(app=mock_app)

            # Add a subscription
            mock_request = MagicMock()
            mock_request.sid = "failing-client"

            service._SocketIOService__handle_subscribe(
                {"research_id": "error-research"}, mock_request
            )

            # Subscription should still be added despite emit error
            # (emit error is for sending current status, not for adding to set)
            subs = service._SocketIOService__socket_subscriptions
            assert "error-research" in subs
            assert "failing-client" in subs["error-research"]

            # Now disconnect - should clean up even with errors
            with patch(
                "src.local_deep_research.web.services.socket_service.logger"
            ):
                service._SocketIOService__handle_disconnect(
                    mock_request, "close"
                )

            # __handle_disconnect is keyed by research_id -> set of sids: it
            # discards the disconnecting sid from every set and drops any key
            # whose set is now empty. "failing-client" was the sole subscriber
            # to "error-research", so the entire key is removed.
            assert "failing-client" not in subs.get("error-research", set())
            assert "error-research" not in subs
