"""
Tests for the SocketIOService class.

Tests cover:
- Singleton pattern
- Event emission
- Subscriber management
- Error handling
"""

from unittest.mock import MagicMock, patch


class MockFlaskApp:
    """Mock Flask application for testing."""

    def __init__(self):
        self.config = {}
        self.debug = False


class MockSocketIO:
    """Mock SocketIO for testing."""

    def __init__(self, app=None, **kwargs):
        self.app = app
        self.kwargs = kwargs
        self.emitted_events = []
        self._handlers = {}

    def emit(self, event, data, room=None):
        self.emitted_events.append({"event": event, "data": data, "room": room})

    def on(self, event):
        def decorator(f):
            self._handlers[event] = f
            return f

        return decorator

    @property
    def on_error(self):
        def decorator(f):
            self._handlers["error"] = f
            return f

        return decorator

    @property
    def on_error_default(self):
        def decorator(f):
            self._handlers["error_default"] = f
            return f

        return decorator

    def run(self, app, **kwargs):
        pass


class TestSocketIOServiceSingleton:
    """Tests for SocketIOService singleton pattern."""

    def test_singleton_requires_app_first_time(self):
        """SocketIOService requires Flask app on first instantiation."""
        # Reset singleton for test
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        # Store and reset singleton
        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        try:
            # Should raise ValueError when no app provided
            try:
                SocketIOService()
                assert False, "Should have raised ValueError"
            except ValueError as e:
                assert "Flask app must be specified" in str(e)
        finally:
            # Restore original singleton
            SocketIOService._instance = original_instance


class TestSocketIOServiceEmit:
    """Tests for SocketIOService emit methods."""

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_emit_socket_event_broadcast(self, mock_socketio_class):
        """emit_socket_event broadcasts to all clients when no room specified."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        # Reset and create fresh singleton
        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            result = service.emit_socket_event("test_event", {"data": "value"})

            assert result is True
            mock_socketio.emit.assert_called_with(
                "test_event", {"data": "value"}
            )
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_emit_socket_event_to_room(self, mock_socketio_class):
        """emit_socket_event sends to specific room when specified."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            result = service.emit_socket_event(
                "test_event", {"data": "value"}, room="room123"
            )

            assert result is True
            mock_socketio.emit.assert_called_with(
                "test_event", {"data": "value"}, room="room123"
            )
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_emit_socket_event_handles_error(self, mock_socketio_class):
        """emit_socket_event returns False on error."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio.emit.side_effect = Exception("Connection error")
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            result = service.emit_socket_event("test_event", {"data": "value"})

            assert result is False
        finally:
            SocketIOService._instance = original_instance


class TestSocketIOServiceSubscribers:
    """Tests for SocketIOService subscriber management."""

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_emit_to_subscribers_success(self, mock_socketio_class):
        """emit_to_subscribers emits to research channel."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            result = service.emit_to_subscribers(
                "progress", "research_123", {"percent": 50}
            )

            assert result is True
            # Should emit to the formatted channel
            mock_socketio.emit.assert_called_with(
                "progress_research_123", {"percent": 50}
            )
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_emit_to_subscribers_handles_error(self, mock_socketio_class):
        """emit_to_subscribers returns False on error."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio.emit.side_effect = Exception("Emit failed")
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            result = service.emit_to_subscribers(
                "progress", "research_123", {"percent": 50}
            )

            assert result is False
        finally:
            SocketIOService._instance = original_instance


class TestSocketIOServiceRun:
    """Tests for SocketIOService run method."""

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_run_starts_server(self, mock_socketio_class):
        """run method starts the SocketIO server."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            # Mock run to not block
            mock_socketio.run = MagicMock()

            service.run(host="0.0.0.0", port=5000, debug=False)

            mock_socketio.run.assert_called_once()
            call_kwargs = mock_socketio.run.call_args
            assert call_kwargs[1]["host"] == "0.0.0.0"
            assert call_kwargs[1]["port"] == 5000
            assert call_kwargs[1]["debug"] is False
        finally:
            SocketIOService._instance = original_instance


class TestSocketIOServiceInit:
    """Tests for SocketIOService initialization."""

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_service_initializes_with_app(self, mock_socketio_class):
        """Service initializes properly with Flask app."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            # Service should be initialized
            assert service is not None
            # SocketIO should have been created
            mock_socketio_class.assert_called_once()
        finally:
            SocketIOService._instance = original_instance


class TestSocketIOServiceMultipleEmits:
    """Tests for multiple emit scenarios."""

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_multiple_events_same_room(self, mock_socketio_class):
        """Multiple events can be emitted to the same room."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            service.emit_socket_event("event1", {"data": 1}, room="room1")
            service.emit_socket_event("event2", {"data": 2}, room="room1")

            assert mock_socketio.emit.call_count == 2
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_emit_with_namespace(self, mock_socketio_class):
        """Events can be emitted to specific namespaces."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            # Test emit with namespace if supported
            result = service.emit_socket_event(
                "test_event",
                {"data": "value"},
            )

            assert result is True
        finally:
            SocketIOService._instance = original_instance


class TestHandleSubscribe:
    """Tests for subscribe event handling."""

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_subscribe_adds_to_room(self, mock_socketio_class):
        """Subscribe event should add client to research room."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            # Service should register subscribe handler
            assert service is not None
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_subscribe_with_research_id(self, mock_socketio_class):
        """Subscribe with valid research_id."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            # Service should be ready to handle subscriptions
            # The service initializes internal handlers
            assert service is not None
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_subscribe_without_research_id_is_handled(
        self, mock_socketio_class
    ):
        """Subscribe without research_id should be handled gracefully."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            # Service should be able to handle missing research_id
            assert service is not None
        finally:
            SocketIOService._instance = original_instance


class TestEmitToSubscribersAdvanced:
    """Advanced tests for emit_to_subscribers method."""

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_emit_progress_update(self, mock_socketio_class):
        """Emit progress update to subscribers."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            result = service.emit_to_subscribers(
                "progress",
                "research_abc123",
                {"current": 50, "total": 100, "message": "Processing..."},
            )

            assert result is True
            mock_socketio.emit.assert_called_once()
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_emit_status_update(self, mock_socketio_class):
        """Emit status update to subscribers."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            result = service.emit_to_subscribers(
                "status",
                "research_xyz789",
                {"status": "completed", "result_url": "/results/xyz789"},
            )

            assert result is True
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_emit_error_to_subscribers(self, mock_socketio_class):
        """Emit error to subscribers."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            result = service.emit_to_subscribers(
                "error",
                "research_failed123",
                {"error": "Research failed", "code": 500},
            )

            assert result is True
        finally:
            SocketIOService._instance = original_instance


class TestSocketIOServiceSingletonBehavior:
    """Tests for SocketIOService singleton behavior."""

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_singleton_returns_same_instance(self, mock_socketio_class):
        """Second call returns same instance."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service1 = SocketIOService(app=app)
            service2 = SocketIOService()

            assert service1 is service2
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_singleton_ignores_second_app(self, mock_socketio_class):
        """Second app parameter is ignored."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app1 = MockFlaskApp()
            app2 = MockFlaskApp()
            service1 = SocketIOService(app=app1)
            service2 = SocketIOService(app=app2)

            # Should be same instance, app2 ignored
            assert service1 is service2
        finally:
            SocketIOService._instance = original_instance


class TestSocketServiceDisconnectCleanup:
    """Tests for thread cleanup on socket disconnect.

    These tests verify that __handle_disconnect properly calls
    cleanup_current_thread() to prevent file descriptor leaks from
    unclosed SQLAlchemy sessions in socket handler threads.
    """

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_disconnect_handler_exists(self, mock_socketio_class):
        """Test that the disconnect handler is registered."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            # The service should register handlers via @socketio.on decorators
            # We verify the service initializes without error
            assert service is not None
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_disconnect_removes_subscriptions(self, mock_socketio_class):
        """Test that disconnect removes client from all research subscriptions.

        Schema: __socket_subscriptions is research_id → set of sids.
        Disconnect must discard the sid from every research_id's set.
        """
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            # Access the private subscriptions dict
            subscriptions = service._SocketIOService__socket_subscriptions

            # Set up correct schema: research_id → {sids}
            test_sid = "test_client_123"
            subscriptions["research_1"] = {test_sid, "other_client"}
            subscriptions["research_2"] = {test_sid}

            # Create a mock request
            mock_request = MagicMock()
            mock_request.sid = test_sid

            # Call the disconnect handler directly
            service._SocketIOService__handle_disconnect(
                mock_request, "test reason"
            )

            # sid should be removed from research_1, leaving other_client
            assert test_sid not in subscriptions.get("research_1", set())
            assert "other_client" in subscriptions["research_1"]
            # research_2 had only this sid, so the key should be cleaned up
            assert "research_2" not in subscriptions
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_disconnect_leaves_other_clients_intact(self, mock_socketio_class):
        """Test that disconnect only removes the disconnecting client."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            subscriptions = service._SocketIOService__socket_subscriptions
            subscriptions["research_1"] = {"client_A", "client_B", "client_C"}

            mock_request = MagicMock()
            mock_request.sid = "client_B"

            service._SocketIOService__handle_disconnect(
                mock_request, "test reason"
            )

            assert subscriptions["research_1"] == {"client_A", "client_C"}
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_disconnect_noop_when_sid_not_subscribed(self, mock_socketio_class):
        """Test that disconnect is a no-op when sid has no subscriptions."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            subscriptions = service._SocketIOService__socket_subscriptions
            subscriptions["research_1"] = {"other_client_1", "other_client_2"}

            mock_request = MagicMock()
            mock_request.sid = "unknown_client"

            service._SocketIOService__handle_disconnect(
                mock_request, "test reason"
            )

            # Nothing should change
            assert subscriptions["research_1"] == {
                "other_client_1",
                "other_client_2",
            }
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_subscribe_then_disconnect_round_trip(self, mock_socketio_class):
        """Test full subscribe → disconnect cycle uses consistent schema."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            mock_request = MagicMock()
            mock_request.sid = "round_trip_client"

            # Subscribe to two research IDs
            service._SocketIOService__handle_subscribe(
                {"research_id": "r1"}, mock_request
            )
            service._SocketIOService__handle_subscribe(
                {"research_id": "r2"}, mock_request
            )

            subscriptions = service._SocketIOService__socket_subscriptions
            assert "round_trip_client" in subscriptions["r1"]
            assert "round_trip_client" in subscriptions["r2"]

            # Disconnect should clean up both
            service._SocketIOService__handle_disconnect(
                mock_request, "test reason"
            )

            assert "round_trip_client" not in subscriptions.get("r1", set())
            assert "round_trip_client" not in subscriptions.get("r2", set())
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_disconnect_calls_cleanup_current_thread(self, mock_socketio_class):
        """Test that disconnect handler calls cleanup_current_thread()."""
        import inspect
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            # Verify by source code inspection that cleanup_current_thread
            # is called in the disconnect handler
            func_source = inspect.getsource(
                service._SocketIOService__handle_disconnect
            )

            # Check that the cleanup function is imported and called
            assert "cleanup_current_thread" in func_source, (
                "Disconnect handler should call cleanup_current_thread()"
            )
            assert (
                "from ...database.thread_local_session import" in func_source
            ), "Disconnect handler should import from thread_local_session"
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_disconnect_handles_cleanup_import_error_gracefully(
        self, mock_socketio_class
    ):
        """Test that disconnect handles ImportError gracefully."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            mock_request = MagicMock()
            mock_request.sid = "test_client_import_error"

            # The handler should handle ImportError gracefully (pass)
            # This is already handled in the source code with:
            # except ImportError: pass

            # Call should not raise even if import fails
            # (the actual import might succeed or fail depending on environment)
            try:
                service._SocketIOService__handle_disconnect(
                    mock_request, "import error test"
                )
            except ImportError:
                # Should not propagate
                assert False, "ImportError should be caught internally"

            # Handler completed without propagating ImportError
            assert True
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_disconnect_handles_cleanup_exception_gracefully(
        self, mock_socketio_class
    ):
        """Test that disconnect handles cleanup exceptions gracefully."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            mock_request = MagicMock()
            mock_request.sid = "test_client_cleanup_error"

            # The handler has a try/except block that catches Exception
            # and logs it rather than propagating

            # Call should not raise
            try:
                service._SocketIOService__handle_disconnect(
                    mock_request, "cleanup error test"
                )
            except Exception as e:
                # Only outer exceptions should propagate, not cleanup errors
                # The source has: except Exception: self.__log_exception(...)
                if "Error cleaning up thread session" in str(e):
                    assert False, (
                        "Cleanup exception should be caught internally"
                    )

            # Handler completed
            assert True
        finally:
            SocketIOService._instance = original_instance

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    def test_disconnect_logs_client_info(self, mock_socketio_class):
        """Test that disconnect logs client disconnect information."""
        from local_deep_research.web.services.socket_service import (
            SocketIOService,
        )

        original_instance = SocketIOService._instance
        SocketIOService._instance = None

        mock_socketio = MagicMock()
        mock_socketio_class.return_value = mock_socketio

        try:
            app = MockFlaskApp()
            service = SocketIOService(app=app)

            mock_request = MagicMock()
            mock_request.sid = "logged_client_123"

            # Mock the logging method
            with patch.object(
                service, "_SocketIOService__log_info"
            ) as mock_log_info:
                service._SocketIOService__handle_disconnect(
                    mock_request, "client initiated"
                )

                # Should log disconnect info
                assert mock_log_info.called
                # Check that the client ID appears in at least one log call
                log_messages = [
                    str(call) for call in mock_log_info.call_args_list
                ]
                assert any(
                    "logged_client_123" in msg for msg in log_messages
                ), "Client ID should be logged on disconnect"
        finally:
            SocketIOService._instance = original_instance
