from threading import Lock
from typing import Any

from flask import Flask, request
from flask_socketio import SocketIO
from loguru import logger

from ...constants import ResearchStatus
from ..routes.globals import get_active_research_snapshot


class SocketIOService:
    """
    Singleton class for managing SocketIO connections and subscriptions.
    """

    _instance = None

    def __new__(cls, *args: Any, app: Flask | None = None, **kwargs: Any):
        """
        Args:
            app: The Flask app to bind this service to. It must be specified
                the first time this is called and the singleton instance is
                created, but will be ignored after that.
            *args: Arguments to pass to the superclass's __new__ method.
            **kwargs: Keyword arguments to pass to the superclass's __new__ method.
        """
        if not cls._instance:
            if app is None:
                raise ValueError(
                    "Flask app must be specified to create a SocketIOService instance."
                )
            cls._instance = super(SocketIOService, cls).__new__(
                cls, *args, **kwargs
            )
            cls._instance.__init_singleton(app)
        return cls._instance

    def __init_singleton(self, app: Flask) -> None:
        """
        Initializes the singleton instance.

        Args:
            app: The app to bind this service to.

        """
        self.__app = app  # Store the Flask app reference

        # Determine WebSocket CORS policy from env var or default
        from ...settings.env_registry import get_env_setting

        ws_origins_env = get_env_setting("security.websocket.allowed_origins")
        socketio_cors: str | list[str] | None
        if ws_origins_env is not None:
            if ws_origins_env == "*":
                socketio_cors = "*"
            elif ws_origins_env:
                socketio_cors = [o.strip() for o in ws_origins_env.split(",")]
            else:
                socketio_cors = None
        else:
            # No env var set — preserve existing permissive default
            socketio_cors = "*"

        if socketio_cors is None:
            logger.info(
                "Socket.IO CORS: same-origin only (set LDR_SECURITY_WEBSOCKET_ALLOWED_ORIGINS to configure)"
            )
        elif socketio_cors == "*":
            logger.debug("Socket.IO CORS: all origins allowed")
        else:
            logger.info(f"Socket.IO CORS: restricted to {socketio_cors}")

        self.__socketio = SocketIO(
            app,
            cors_allowed_origins=socketio_cors,
            async_mode="threading",
            path="/socket.io",
            logger=False,
            engineio_logger=False,
            ping_timeout=20,
            ping_interval=5,
        )

        # Socket subscription tracking.
        self.__socket_subscriptions: dict[str, Any] = {}
        # Set to false to disable logging in the event handlers. This can
        # be necessary because it will sometimes run the handlers directly
        # during a call to `emit` that was made in a logging handler.
        self.__logging_enabled = True
        # Protects access to shared state.
        self.__lock = Lock()

        # Register events.
        @self.__socketio.on("connect")
        def on_connect():
            self.__handle_connect(request)

        @self.__socketio.on("disconnect")
        def on_disconnect(reason: str):
            self.__handle_disconnect(request, reason)

        @self.__socketio.on("subscribe_to_research")
        def on_subscribe(data):
            self.__handle_subscribe(data, request)

        @self.__socketio.on_error
        def on_error(e):
            return self.__handle_socket_error(e)

        @self.__socketio.on_error_default
        def on_default_error(e):
            return self.__handle_default_error(e)

    def __log_info(self, message: str, *args: Any, **kwargs: Any) -> None:
        """Log an info message."""
        if self.__logging_enabled:
            logger.info(message, *args, **kwargs)

    def __log_error(self, message: str, *args: Any, **kwargs: Any) -> None:
        """Log an error message."""
        if self.__logging_enabled:
            logger.error(message, *args, **kwargs)

    def __log_exception(self, message: str, *args: Any, **kwargs: Any) -> None:
        """Log an exception."""
        if self.__logging_enabled:
            logger.exception(message, *args, **kwargs)

    def emit_socket_event(self, event, data, room=None):
        """
        Emit a socket event to clients.

        Args:
            event: The event name to emit
            data: The data to send with the event
            room: Optional room ID to send to specific client

        Returns:
            bool: True if emission was successful, False otherwise
        """
        try:
            # If room is specified, only emit to that room
            if room:
                self.__socketio.emit(event, data, room=room)
            else:
                # Otherwise broadcast to all
                self.__socketio.emit(event, data)
            return True
        except Exception:
            logger.exception(f"Error emitting socket event {event}")
            return False

    def emit_to_subscribers(
        self, event_base, research_id, data, enable_logging: bool = True
    ):
        """
        Emit an event to all subscribers of a specific research.

        Args:
            event_base: Base event name (will be formatted with research_id)
            research_id: ID of the research
            data: The data to send with the event
            enable_logging: If set to false, this will disable all logging,
                which is useful if we are calling this inside of a logging
                handler.

        Returns:
            bool: True if emission was successful, False otherwise

        """
        if not enable_logging:
            self.__logging_enabled = False

        try:
            full_event = f"{event_base}_{research_id}"

            # Emit only to specific subscribers (no broadcast) to avoid
            # duplicate messages and reduce server load under concurrency
            with self.__lock:
                subscriptions = self.__socket_subscriptions.get(research_id)
                if subscriptions:
                    subscriptions = (
                        subscriptions.copy()
                    )  # snapshot avoids RuntimeError
                else:
                    subscriptions = None
            if subscriptions is not None:
                for sid in subscriptions:
                    try:
                        self.__socketio.emit(full_event, data, room=sid)
                    except Exception:
                        self.__log_exception(
                            f"Error emitting to subscriber {sid}"
                        )
            else:
                # No targeted subscribers yet — broadcast so early
                # listeners still receive the event
                self.__socketio.emit(full_event, data)

            return True
        except Exception:
            self.__log_exception(
                f"Error emitting to subscribers for research {research_id}"
            )
            return False
        finally:
            self.__logging_enabled = True

    def remove_subscriptions_for_research(self, research_id: str) -> None:
        """Remove all socket subscriptions for a completed research."""
        with self.__lock:
            removed = self.__socket_subscriptions.pop(research_id, None)
        if removed is not None:
            self.__log_info(
                f"Removed {len(removed)} subscription(s) for research {research_id}"
            )

    def __handle_connect(self, request):
        """Handle client connection"""
        self.__log_info(f"Client connected: {request.sid}")

    def __handle_disconnect(self, request, reason: str):
        """Handle client disconnection"""
        try:
            self.__log_info(
                f"Client {request.sid} disconnected because: {reason}"
            )
            # Clean up subscriptions for this client.
            # __socket_subscriptions is keyed by research_id → set of sids,
            # so we iterate all entries and discard the disconnecting sid.
            with self.__lock:
                empty_keys = []
                for research_id, sids in self.__socket_subscriptions.items():
                    sids.discard(request.sid)
                    if not sids:
                        empty_keys.append(research_id)
                for key in empty_keys:
                    del self.__socket_subscriptions[key]
            self.__log_info(f"Removed subscription for client {request.sid}")

            # Clean up any thread-local database sessions that may have been
            # created during socket handler execution. This prevents file
            # descriptor leaks from unclosed SQLAlchemy sessions.
            try:
                from ...database.thread_local_session import (
                    cleanup_current_thread,
                )

                cleanup_current_thread()
            except ImportError:
                pass  # Module not available, skip cleanup
            except Exception:
                self.__log_exception(
                    "Error cleaning up thread session on disconnect"
                )
        except Exception as e:
            self.__log_exception(f"Error handling disconnect: {e}")

    def __handle_subscribe(self, data, request):
        """Handle client subscription to research updates"""
        research_id = data.get("research_id")
        if research_id:
            # Initialize subscription set if needed
            with self.__lock:
                if research_id not in self.__socket_subscriptions:
                    self.__socket_subscriptions[research_id] = set()

                # Add this client to the subscribers
                self.__socket_subscriptions[research_id].add(request.sid)
            self.__log_info(
                f"Client {request.sid} subscribed to research {research_id}"
            )

            # Send current status immediately if available in active research
            snapshot = get_active_research_snapshot(research_id)
            if snapshot is not None:
                progress = snapshot["progress"]
                latest_log = snapshot["log"][-1] if snapshot["log"] else None

                if latest_log:
                    self.emit_socket_event(
                        f"progress_{research_id}",
                        {
                            "progress": progress,
                            "message": latest_log.get(
                                "message", "Processing..."
                            ),
                            "status": ResearchStatus.IN_PROGRESS,
                            "log_entry": latest_log,
                        },
                        room=request.sid,
                    )

    def __handle_socket_error(self, e):
        """Handle Socket.IO errors"""
        self.__log_exception(f"Socket.IO error: {str(e)}")
        # Don't propagate exceptions to avoid crashing the server
        return False

    def __handle_default_error(self, e):
        """Handle unhandled Socket.IO errors"""
        self.__log_exception(f"Unhandled Socket.IO error: {str(e)}")
        # Don't propagate exceptions to avoid crashing the server
        return False

    def run(self, host: str, port: int, debug: bool = False) -> None:
        """
        Runs the SocketIO server.

        Args:
            host: The hostname to bind the server to.
            port: The port number to listen on.
            debug: Whether to run in debug mode. Defaults to False.

        """
        # Suppress Server header to prevent version information disclosure
        # This must be done before starting the server because Werkzeug adds
        # the header at the HTTP layer, not WSGI layer
        try:
            from werkzeug.serving import WSGIRequestHandler

            WSGIRequestHandler.version_string = lambda self: ""  # type: ignore[method-assign]
            logger.debug("Suppressed Server header for security")
        except ImportError:
            logger.warning(
                "Could not suppress Server header - werkzeug not found"
            )

        logger.info(f"Starting web server on {host}:{port} (debug: {debug})")
        self.__socketio.run(
            self.__app,  # Use the stored Flask app reference
            debug=debug,
            host=host,
            port=port,
            allow_unsafe_werkzeug=True,
            use_reloader=False,
        )
