"""Tests for server/lifecycle.py module.

Tests cover server lifecycle management: start, run, shutdown.
"""

import asyncio
import signal
import sys
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from mcp_hangar.server.bootstrap import ApplicationContext
from mcp_hangar.server.cli import CLIConfig
from mcp_hangar.server.lifecycle import _setup_signal_handlers, run_server, ServerLifecycle


def _close_run_coro(coro: object, *args: object, **kwargs: object) -> None:
    """``asyncio.run`` stand-in that closes the coroutine it is handed.

    ``run_http`` builds an inner ``run_server()`` coroutine and passes it to
    ``asyncio.run``. A plain MagicMock never awaits it, leaking it as
    ``RuntimeWarning: coroutine 'run_server' was never awaited``; closing it
    keeps the mocked-out behaviour without the warning.
    """
    if asyncio.iscoroutine(coro):
        coro.close()


class TestServerLifecycle:
    """Tests for ServerLifecycle class."""

    @pytest.fixture
    def mock_context(self):
        """Create a mock ApplicationContext."""
        mock_runtime = MagicMock()
        mock_runtime.repository.get_all_ids.return_value = []

        mock_mcp = MagicMock()
        mock_worker1 = MagicMock()
        mock_worker1.task = "gc"
        mock_worker2 = MagicMock()
        mock_worker2.task = "health_check"

        ctx = ApplicationContext(
            runtime=mock_runtime,
            mcp_server=mock_mcp,
            background_workers=[mock_worker1, mock_worker2],
            discovery_orchestrator=None,
            config={},
        )
        return ctx

    def test_lifecycle_init(self, mock_context):
        """ServerLifecycle should initialize correctly."""
        lifecycle = ServerLifecycle(mock_context)

        assert lifecycle._context == mock_context
        assert lifecycle._running is False
        assert lifecycle._shutdown_requested is False

    def test_lifecycle_is_running(self, mock_context):
        """is_running property should reflect state."""
        lifecycle = ServerLifecycle(mock_context)

        assert lifecycle.is_running is False

        lifecycle._running = True
        assert lifecycle.is_running is True

    def test_lifecycle_start(self, mock_context):
        """start() should start background workers."""
        lifecycle = ServerLifecycle(mock_context)
        lifecycle.start()

        assert lifecycle._running is True
        for worker in mock_context.background_workers:
            worker.start.assert_called_once()

    def test_lifecycle_start_idempotent(self, mock_context):
        """start() should be idempotent."""
        lifecycle = ServerLifecycle(mock_context)
        lifecycle.start()
        lifecycle.start()  # Second call should be no-op

        # Each worker's start should only be called once
        for worker in mock_context.background_workers:
            assert worker.start.call_count == 1

    def test_lifecycle_start_with_discovery(self):
        """Discovery starts through the dedicated long-lived loop."""
        mock_runtime = MagicMock()
        mock_runtime.repository.get_all_ids.return_value = []
        mock_mcp = MagicMock()
        mock_orchestrator = MagicMock()
        mock_orchestrator.get_stats.return_value = {"sources_count": 2}

        ctx = ApplicationContext(
            runtime=mock_runtime,
            mcp_server=mock_mcp,
            background_workers=[],
            discovery_orchestrator=mock_orchestrator,
            config={},
        )

        lifecycle = ServerLifecycle(ctx)

        with patch.object(lifecycle, "_start_discovery") as start_discovery:
            lifecycle.start()

        start_discovery.assert_called_once()

    def test_discovery_start_and_stop_share_dedicated_loop(self, mock_context):
        """Discovery lifecycle work stays off the HTTP and stdio transport loops."""
        loops = []

        async def start():
            loops.append(asyncio.get_running_loop())

        async def stop():
            loops.append(asyncio.get_running_loop())

        mock_context.discovery_orchestrator = MagicMock()
        mock_context.discovery_orchestrator.start.side_effect = start
        mock_context.discovery_orchestrator.stop.side_effect = stop
        mock_context.discovery_orchestrator.get_stats.return_value = {"sources_count": 1}
        lifecycle = ServerLifecycle(mock_context)

        lifecycle.start()
        lifecycle.shutdown()

        assert len(loops) == 2
        assert loops[0] is loops[1]
        # The loop and its thread are released on shutdown, not merely stopped:
        # a retained loop kept the process alive after `shutdown()` returned.
        assert lifecycle._discovery_loop is None
        assert lifecycle._discovery_thread is None

    def test_lifecycle_shutdown(self, mock_context):
        """shutdown() should stop all components."""
        lifecycle = ServerLifecycle(mock_context)
        lifecycle._running = True

        # Mock the context's shutdown behavior
        with patch.object(mock_context, "shutdown", MagicMock()) as mock_shutdown:
            lifecycle.shutdown()

            assert lifecycle._shutdown_requested is True
            assert lifecycle._running is False
            mock_shutdown.assert_called_once()

    def test_lifecycle_shutdown_idempotent(self, mock_context):
        """shutdown() should be idempotent."""
        lifecycle = ServerLifecycle(mock_context)

        with patch.object(mock_context, "shutdown", MagicMock()) as mock_shutdown:
            lifecycle.shutdown()
            lifecycle.shutdown()  # Second call should be no-op

            # shutdown on context should only be called once
            assert mock_shutdown.call_count == 1

    def test_the_lease_is_released_only_after_the_context_has_shut_down(self, mock_context):
        """A peer takes over once everything this instance ran under the lease has stopped."""
        order: list[str] = []
        keeper = MagicMock()
        keeper.stop.side_effect = lambda: order.append("lease released")
        lifecycle = ServerLifecycle(mock_context)

        with (
            patch.object(mock_context, "shutdown", side_effect=lambda: order.append("context shut down")),
            patch("mcp_hangar.server.lifecycle.get_lease_keeper", return_value=keeper),
        ):
            lifecycle.shutdown()

        assert order == ["context shut down", "lease released"]

    def test_run_stdio(self, mock_context):
        """run_stdio() should call mcp_server.run()."""
        lifecycle = ServerLifecycle(mock_context)
        lifecycle.run_stdio()

        mock_context.mcp_server.run.assert_called_once()

    def test_run_stdio_handles_keyboard_interrupt(self, mock_context):
        """run_stdio() should handle KeyboardInterrupt gracefully."""
        mock_context.mcp_server.run.side_effect = KeyboardInterrupt()

        lifecycle = ServerLifecycle(mock_context)
        # Should not raise
        lifecycle.run_stdio()

    def test_run_stdio_exits_on_fatal_error(self, mock_context):
        """run_stdio() should exit on fatal error."""
        mock_context.mcp_server.run.side_effect = RuntimeError("Fatal error")

        lifecycle = ServerLifecycle(mock_context)

        with pytest.raises(SystemExit) as exc_info:
            lifecycle.run_stdio()

        assert exc_info.value.code == 1

    def test_run_http(self, mock_context):
        """run_http() should configure and run uvicorn."""
        mock_uvicorn = MagicMock()
        sys.modules["uvicorn"] = mock_uvicorn

        try:
            with patch("asyncio.run") as mock_asyncio_run:
                mock_asyncio_run.side_effect = _close_run_coro

                lifecycle = ServerLifecycle(mock_context)
                lifecycle.run_http("127.0.0.1", 9000)

            # Verify MCP server settings were updated
            assert mock_context.mcp_server.settings.host == "127.0.0.1"
            assert mock_context.mcp_server.settings.port == 9000
        finally:
            del sys.modules["uvicorn"]

    def test_run_http_does_not_let_uvicorn_rewrite_the_peer(self, mock_context):
        """uvicorn's forwarded-header handling is off (GHSA-fhwh-fmq2-7m5c).

        On, it replaced a loopback peer with its X-Forwarded-For address before
        Hangar saw the request, so `MCP_TRUSTED_PROXIES` was not the decision it
        claims to be and a loopback proxy's x-session-id was ignored.
        """
        mock_uvicorn = MagicMock()
        sys.modules["uvicorn"] = mock_uvicorn

        try:
            with patch("asyncio.run") as mock_asyncio_run:
                mock_asyncio_run.side_effect = _close_run_coro
                ServerLifecycle(mock_context).run_http("127.0.0.1", 9000)

            assert mock_uvicorn.Config.call_args.kwargs["proxy_headers"] is False
        finally:
            del sys.modules["uvicorn"]

    def test_run_http_wraps_the_front_door(self, mock_context):
        """run_http() must wrap the MCP app in SEP-2243 front-door routing (#560).

        The wrap used to exist only in MCPServerFactory, which nothing calls, so
        the shipped serve path enforced no header/body agreement for legacy-era
        POSTs carrying Mcp-Method / Mcp-Name.
        """
        mock_uvicorn = MagicMock()
        sys.modules["uvicorn"] = mock_uvicorn

        try:
            with (
                patch("asyncio.run") as mock_asyncio_run,
                patch("mcp_hangar.fastmcp_server.modern_surface.wrap_front_door_routing") as mock_wrap,
            ):
                mock_asyncio_run.side_effect = _close_run_coro

                ServerLifecycle(mock_context).run_http("127.0.0.1", 9000)

            mock_wrap.assert_called_once_with(mock_context.mcp_server.streamable_http_app.return_value)
        finally:
            del sys.modules["uvicorn"]

    def test_run_http_handles_keyboard_interrupt(self, mock_context):
        """run_http() should handle KeyboardInterrupt gracefully."""
        mock_uvicorn = MagicMock()
        sys.modules["uvicorn"] = mock_uvicorn

        try:

            def _close_then_interrupt(coro: object, *args: object, **kwargs: object) -> None:
                _close_run_coro(coro)
                raise KeyboardInterrupt

            with patch("asyncio.run") as mock_asyncio_run:
                mock_asyncio_run.side_effect = _close_then_interrupt

                lifecycle = ServerLifecycle(mock_context)
                # Should not raise
                lifecycle.run_http("localhost", 8000)
        finally:
            del sys.modules["uvicorn"]

    def test_run_http_refuses_non_loopback_without_auth(self, mock_context):
        """run_http() should refuse non-loopback binding when auth is disabled."""
        mock_context.auth_components = None
        lifecycle = ServerLifecycle(mock_context)

        with pytest.raises(SystemExit) as exc_info:
            lifecycle.run_http("0.0.0.0", 8000)

        assert exc_info.value.code == 1

    async def test_create_auth_app_rejects_websocket_without_credentials(self, mock_context):
        """Auth wrapper should close websocket with 1008 on auth failure."""
        from mcp_hangar.domain.exceptions import MissingCredentialsError

        auth_components = MagicMock()
        auth_components.authn_middleware.authenticate.side_effect = MissingCredentialsError()
        inner_app = AsyncMock()
        lifecycle = ServerLifecycle(mock_context)
        auth_app = lifecycle._create_auth_app(inner_app, auth_components)

        sent_messages = []

        async def send(message):
            sent_messages.append(message)

        scope = {
            "type": "websocket",
            "path": "/api/ws/events",
            "headers": [],
            "client": ("127.0.0.1", 1234),
            "query_string": b"",
        }

        await auth_app(scope, AsyncMock(), send)

        inner_app.assert_not_called()
        assert sent_messages == [{"type": "websocket.close", "code": 1008, "reason": "No credentials provided"}]

    async def test_create_auth_app_adds_bearer_token_from_websocket_query(self, mock_context):
        """Auth wrapper should map websocket ?token= to Authorization header."""
        auth_components = MagicMock()
        auth_components.authn_middleware.authenticate.return_value = {"principal": "ok"}
        inner_app = AsyncMock()
        lifecycle = ServerLifecycle(mock_context)
        auth_app = lifecycle._create_auth_app(inner_app, auth_components)

        scope = {
            "type": "websocket",
            "path": "/api/ws/events",
            "headers": [],
            "client": ("127.0.0.1", 1234),
            "query_string": b"token=test-token",
        }

        await auth_app(scope, AsyncMock(), AsyncMock())

        auth_request = auth_components.authn_middleware.authenticate.call_args.args[0]
        assert auth_request.headers["authorization"] == "Bearer test-token"
        inner_app.assert_awaited_once()


class TestSetupSignalHandlers:
    """Tests for _setup_signal_handlers function."""

    def test_registers_sigterm_handler(self):
        """Should register SIGTERM handler."""
        mock_context = MagicMock()
        lifecycle = ServerLifecycle(mock_context)

        with patch("mcp_hangar.server.lifecycle.signal.signal") as mock_signal:
            _setup_signal_handlers(lifecycle)

        # Check SIGTERM was registered
        sigterm_call = None
        for call in mock_signal.call_args_list:
            if call[0][0] == signal.SIGTERM:
                sigterm_call = call
                break

        assert sigterm_call is not None

    def test_registers_sigint_handler(self):
        """Should register SIGINT handler."""
        mock_context = MagicMock()
        lifecycle = ServerLifecycle(mock_context)

        with patch("mcp_hangar.server.lifecycle.signal.signal") as mock_signal:
            _setup_signal_handlers(lifecycle)

        # Check SIGINT was registered
        sigint_call = None
        for call in mock_signal.call_args_list:
            if call[0][0] == signal.SIGINT:
                sigint_call = call
                break

        assert sigint_call is not None


class TestRunServer:
    """Tests for run_server function."""

    @pytest.fixture
    def mock_cli_config(self):
        """Create a mock CLIConfig."""
        return CLIConfig(
            http_mode=False,
            http_host="0.0.0.0",
            http_port=8000,
            config_path=None,
            log_file=None,
            log_level="INFO",
            json_logs=False,
        )

    @pytest.fixture
    def mock_dependencies(self):
        """Mock all dependencies for run_server."""
        with (
            patch("mcp_hangar.server.lifecycle.setup_logging") as mock_setup_log,
            patch("mcp_hangar.server.lifecycle.bootstrap") as mock_bootstrap,
            patch("mcp_hangar.server.lifecycle._setup_signal_handlers") as mock_signals,
            patch("mcp_hangar.server.lifecycle.get_discovery_orchestrator") as mock_get_disc,
            patch("mcp_hangar.server.lifecycle.ServerLifecycle") as MockLifecycle,
        ):
            mock_context = MagicMock()
            mock_context.runtime.repository.get_all_ids.return_value = ["provider1"]
            mock_bootstrap.return_value = mock_context
            mock_get_disc.return_value = None

            mock_lifecycle_instance = MagicMock()
            MockLifecycle.return_value = mock_lifecycle_instance

            yield {
                "setup_log": mock_setup_log,
                "bootstrap": mock_bootstrap,
                "signals": mock_signals,
                "get_disc": mock_get_disc,
                "Lifecycle": MockLifecycle,
                "lifecycle_instance": mock_lifecycle_instance,
                "context": mock_context,
            }

    def test_run_server_stdio_mode(self, mock_cli_config, mock_dependencies):
        """run_server() should run stdio mode by default."""
        run_server(mock_cli_config)

        mock_dependencies["lifecycle_instance"].start.assert_called_once()
        mock_dependencies["lifecycle_instance"].run_stdio.assert_called_once()
        mock_dependencies["lifecycle_instance"].run_http.assert_not_called()

    def test_run_server_http_mode(self, mock_dependencies):
        """run_server() should run HTTP mode when configured."""
        http_config = CLIConfig(
            http_mode=True,
            http_host="localhost",
            http_port=9000,
            config_path=None,
            log_file=None,
            log_level="INFO",
            json_logs=False,
        )

        run_server(http_config)

        mock_dependencies["lifecycle_instance"].start.assert_called_once()
        mock_dependencies["lifecycle_instance"].run_http.assert_called_once_with(
            "localhost", 9000, unsafe_no_auth=False
        )
        mock_dependencies["lifecycle_instance"].run_stdio.assert_not_called()

    def test_run_server_setup_logging(self, mock_cli_config, mock_dependencies):
        """run_server() should setup logging."""
        run_server(mock_cli_config)

        mock_dependencies["setup_log"].assert_called()

    def test_run_server_calls_bootstrap(self, mock_cli_config, mock_dependencies):
        """run_server() should call bootstrap."""
        run_server(mock_cli_config)

        # The transport rides along: `auth.stdio.principal` is read only when
        # this process serves over stdio (ADR-026).
        mock_dependencies["bootstrap"].assert_called_once_with(None, stdio=True)

    def test_run_server_with_config_path(self, mock_dependencies):
        """run_server() should pass config path to bootstrap."""
        config = CLIConfig(
            http_mode=False,
            http_host="0.0.0.0",
            http_port=8000,
            config_path="/path/to/config.yaml",
            log_file=None,
            log_level="INFO",
            json_logs=False,
        )

        run_server(config)

        mock_dependencies["bootstrap"].assert_called_once_with("/path/to/config.yaml", stdio=True)

    def test_run_server_setup_signal_handlers(self, mock_cli_config, mock_dependencies):
        """run_server() should setup signal handlers."""
        run_server(mock_cli_config)

        mock_dependencies["signals"].assert_called_once()

    def test_run_server_ensures_shutdown_on_exit(self, mock_cli_config, mock_dependencies):
        """run_server() should call shutdown in finally block."""
        run_server(mock_cli_config)

        mock_dependencies["lifecycle_instance"].shutdown.assert_called_once()

    def test_run_server_shutdown_on_exception(self, mock_dependencies):
        """run_server() should shutdown even on exception."""
        config = CLIConfig(
            http_mode=False,
            http_host="0.0.0.0",
            http_port=8000,
            config_path=None,
            log_file=None,
            log_level="INFO",
            json_logs=False,
        )

        # Make run_stdio raise an exception that's caught by finally
        mock_dependencies["lifecycle_instance"].run_stdio.side_effect = Exception("Test error")

        with pytest.raises(Exception, match="Test error"):
            run_server(config)

        # Shutdown should still be called
        mock_dependencies["lifecycle_instance"].shutdown.assert_called_once()
