"""Tests for SocketIOService.__init_singleton and singleton behavior."""

from unittest.mock import patch
import pytest
from flask import Flask

from local_deep_research.web.services.socket_service import SocketIOService


@pytest.fixture(autouse=True)
def reset_singleton():
    """Reset singleton before each test."""
    SocketIOService._instance = None
    yield
    SocketIOService._instance = None


@pytest.fixture
def minimal_app():
    """Create a minimal Flask app (no create_app() which triggers SocketIOService)."""
    app = Flask(__name__)
    app.config["SECRET_KEY"] = "test"
    app.config["TESTING"] = True
    return app


class TestSocketIOServiceSingleton:
    """Tests for singleton creation and behavior."""

    def test_requires_app_on_first_creation(self):
        """Should raise ValueError if no app provided on first init."""
        with pytest.raises(ValueError, match="Flask app must be specified"):
            SocketIOService()

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    @patch("local_deep_research.settings.env_registry.get_env_setting")
    def test_creates_singleton_with_app(
        self, mock_env, mock_socketio, minimal_app
    ):
        mock_env.return_value = None

        service = SocketIOService(app=minimal_app)
        assert service is not None
        assert SocketIOService._instance is service

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    @patch("local_deep_research.settings.env_registry.get_env_setting")
    def test_second_call_returns_same_instance(
        self, mock_env, mock_socketio, minimal_app
    ):
        mock_env.return_value = None

        first = SocketIOService(app=minimal_app)
        second = SocketIOService(app=minimal_app)
        assert first is second

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    @patch("local_deep_research.settings.env_registry.get_env_setting")
    def test_second_call_without_app_returns_existing(
        self, mock_env, mock_socketio, minimal_app
    ):
        mock_env.return_value = None

        first = SocketIOService(app=minimal_app)
        second = SocketIOService()  # No app needed on second call
        assert first is second


class TestSocketIOServiceCorsConfig:
    """Tests for WebSocket CORS configuration in __init_singleton."""

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    @patch("local_deep_research.settings.env_registry.get_env_setting")
    def test_default_cors_is_same_origin(
        self, mock_env, mock_socketio, minimal_app
    ):
        """No env var set -> secure same-origin-only default (None), per #3091."""
        mock_env.return_value = None

        SocketIOService(app=minimal_app)
        mock_socketio.assert_called_once()
        call_kwargs = mock_socketio.call_args[1]
        assert call_kwargs["cors_allowed_origins"] is None

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    @patch("local_deep_research.settings.env_registry.get_env_setting")
    def test_wildcard_env_setting(self, mock_env, mock_socketio, minimal_app):
        mock_env.return_value = "*"

        SocketIOService(app=minimal_app)
        call_kwargs = mock_socketio.call_args[1]
        assert call_kwargs["cors_allowed_origins"] == "*"

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    @patch("local_deep_research.settings.env_registry.get_env_setting")
    def test_specific_origins(self, mock_env, mock_socketio, minimal_app):
        mock_env.return_value = "https://a.com, https://b.com"

        SocketIOService(app=minimal_app)
        call_kwargs = mock_socketio.call_args[1]
        assert call_kwargs["cors_allowed_origins"] == [
            "https://a.com",
            "https://b.com",
        ]

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    @patch("local_deep_research.settings.env_registry.get_env_setting")
    def test_empty_env_disables_cors(
        self, mock_env, mock_socketio, minimal_app
    ):
        """Empty string env var -> same-origin only (None)."""
        mock_env.return_value = ""

        SocketIOService(app=minimal_app)
        call_kwargs = mock_socketio.call_args[1]
        assert call_kwargs["cors_allowed_origins"] is None

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    @patch("local_deep_research.settings.env_registry.get_env_setting")
    def test_socketio_async_mode_threading(
        self, mock_env, mock_socketio, minimal_app
    ):
        mock_env.return_value = None

        SocketIOService(app=minimal_app)
        call_kwargs = mock_socketio.call_args[1]
        assert call_kwargs["async_mode"] == "threading"

    @patch("local_deep_research.web.services.socket_service.SocketIO")
    @patch("local_deep_research.settings.env_registry.get_env_setting")
    def test_socketio_path(self, mock_env, mock_socketio, minimal_app):
        mock_env.return_value = None

        SocketIOService(app=minimal_app)
        call_kwargs = mock_socketio.call_args[1]
        assert call_kwargs["path"] == "/socket.io"


class TestOriginRejectionLogging:
    """The diagnostic hook that surfaces engine.io's silenced 'bad-origin'
    WebSocket rejections through loguru (so a misconfigured origin isn't a
    silent frozen UI)."""

    def test_logs_each_rejected_origin_once_and_ignores_other_errors(self):
        from loguru import logger
        from flask import Flask
        from flask_socketio import SocketIO
        from local_deep_research.web.services.socket_service import (
            _install_origin_rejection_logging,
        )

        app = Flask(__name__)
        app.config["SECRET_KEY"] = "test"
        socketio = SocketIO(
            app, async_mode="threading", cors_allowed_origins=None
        )

        assert _install_origin_rejection_logging(socketio) is True
        eio = socketio.server.eio

        # The package disables loguru by default (__init__.py); app startup
        # re-enables it. Enable it here so the warning emitted from inside the
        # socket_service module reaches our sink.
        logger.enable("local_deep_research")
        warnings: list[str] = []
        sink_id = logger.add(
            lambda m: warnings.append(m.record["message"]), level="WARNING"
        )
        try:
            eio._log_error_once(
                "https://evil.com is not an accepted origin.", "bad-origin"
            )
            # same origin again -> deduped (no second warning)
            eio._log_error_once(
                "https://evil.com is not an accepted origin.", "bad-origin"
            )
            # a different origin -> its own warning
            eio._log_error_once(
                "https://other.com is not an accepted origin.", "bad-origin"
            )
            # an unrelated engine.io error -> must NOT be turned into a warning
            eio._log_error_once("Invalid transport", "bad-transport")
        finally:
            logger.remove(sink_id)
            logger.disable("local_deep_research")  # restore import-time default

        origin_warnings = [
            w for w in warnings if "rejected a WebSocket handshake" in w
        ]
        assert len(origin_warnings) == 2
        assert any("evil.com" in w for w in origin_warnings)
        assert any("other.com" in w for w in origin_warnings)
        # the fix hint is present; the unrelated error never warned
        assert all(
            "LDR_SECURITY_WEBSOCKET_ALLOWED_ORIGINS" in w
            for w in origin_warnings
        )
        assert not any("Invalid transport" in w for w in warnings)

    def test_returns_false_when_engineio_internals_absent(self):
        from unittest.mock import Mock
        from local_deep_research.web.services.socket_service import (
            _install_origin_rejection_logging,
        )

        broken = Mock()
        # No .server.eio._log_error_once -> AttributeError path -> no-op.
        del broken.server
        assert _install_origin_rejection_logging(broken) is False

    def test_dedup_set_is_capped(self):
        """The per-origin dedup set is bounded: Origin is attacker-controlled at
        the pre-auth handshake, so feeding many distinct origins must not warn
        (or grow) without limit."""
        from loguru import logger
        from flask import Flask
        from flask_socketio import SocketIO
        from local_deep_research.web.services.socket_service import (
            _install_origin_rejection_logging,
        )

        app = Flask(__name__)
        app.config["SECRET_KEY"] = "test"
        socketio = SocketIO(
            app, async_mode="threading", cors_allowed_origins=None
        )
        assert _install_origin_rejection_logging(socketio) is True
        eio = socketio.server.eio

        logger.enable("local_deep_research")
        warnings: list[str] = []
        sink_id = logger.add(
            lambda m: warnings.append(m.record["message"]), level="WARNING"
        )
        try:
            for i in range(250):
                eio._log_error_once(
                    f"https://h{i}.example is not an accepted origin.",
                    "bad-origin",
                )
        finally:
            logger.remove(sink_id)
            logger.disable("local_deep_research")

        origin_warnings = [
            w for w in warnings if "rejected a WebSocket handshake" in w
        ]
        # Capped at 100 despite 250 distinct origins fed in.
        assert len(origin_warnings) == 100
