"""Unit tests for graceful shutdown signal handling.

These tests verify that the server properly handles SIGTERM and SIGINT signals,
exiting cleanly within the expected timeout period.
"""

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

import pytest


class TestSignalHandlerSetup:
    """Tests for signal handler registration."""

    def test_setup_signal_handlers_registers_sigterm(self):
        """Signal handlers should register SIGTERM handler."""
        import ha_mcp.__main__ as main_module

        with patch("signal.signal") as mock_signal:
            main_module._setup_signal_handlers()
            # Check that SIGTERM handler was registered
            calls = [
                call
                for call in mock_signal.call_args_list
                if call[0][0] == signal.SIGTERM
            ]
            assert len(calls) == 1
            assert calls[0][0][1] == main_module._signal_handler

    def test_setup_signal_handlers_registers_sigint(self):
        """Signal handlers should register SIGINT handler."""
        import ha_mcp.__main__ as main_module

        with patch("signal.signal") as mock_signal:
            main_module._setup_signal_handlers()
            # Check that SIGINT handler was registered
            calls = [
                call
                for call in mock_signal.call_args_list
                if call[0][0] == signal.SIGINT
            ]
            assert len(calls) == 1
            assert calls[0][0][1] == main_module._signal_handler


class TestSignalHandler:
    """Tests for the signal handler function."""

    def test_first_signal_sets_shutdown_in_progress(self):
        """First signal should set shutdown_in_progress flag."""
        import ha_mcp.__main__ as main_module

        # Reset global state
        main_module._shutdown_in_progress = False
        main_module._shutdown_event = None

        # Call signal handler
        main_module._signal_handler(signal.SIGTERM, None)

        assert main_module._shutdown_in_progress is True

    def test_first_signal_with_event_sets_event(self):
        """First signal should set shutdown event if available."""
        import ha_mcp.__main__ as main_module

        # Reset global state
        main_module._shutdown_in_progress = False

        # Create a mock event
        mock_event = MagicMock()
        main_module._shutdown_event = mock_event

        # Mock the event loop
        mock_loop = MagicMock()
        with patch("asyncio.get_running_loop", return_value=mock_loop):
            main_module._signal_handler(signal.SIGTERM, None)

        # Verify event.set was scheduled
        mock_loop.call_soon_threadsafe.assert_called_once_with(mock_event.set)

    def test_second_signal_forces_exit(self):
        """Second signal should force immediate exit."""
        import ha_mcp.__main__ as main_module

        # Set up as if first signal was already received
        main_module._shutdown_in_progress = True
        main_module._shutdown_event = None

        # Second signal should call sys.exit(1)
        with pytest.raises(SystemExit) as exc_info:
            main_module._signal_handler(signal.SIGINT, None)

        assert exc_info.value.code == 1

    def test_signal_without_event_loop_exits(self):
        """Signal without event loop should exit gracefully."""
        import ha_mcp.__main__ as main_module

        # Reset global state
        main_module._shutdown_in_progress = False
        main_module._shutdown_event = MagicMock()

        # Make get_running_loop raise RuntimeError (no running loop)
        with (
            patch(
                "asyncio.get_running_loop", side_effect=RuntimeError("no running loop")
            ),
            pytest.raises(SystemExit) as exc_info,
        ):
            main_module._signal_handler(signal.SIGTERM, None)

        assert exc_info.value.code == 0


class TestCleanupResources:
    """Tests for resource cleanup function."""

    @pytest.mark.asyncio
    async def test_cleanup_stops_websocket_listener(self):
        """Cleanup should stop the WebSocket listener service."""
        import ha_mcp.__main__ as main_module

        mock_stop = AsyncMock()

        with (
            patch(
                "ha_mcp.client.websocket_listener.stop_websocket_listener", mock_stop
            ),
            patch(
                "ha_mcp.client.websocket_client.websocket_manager",
                MagicMock(disconnect=AsyncMock()),
            ),
        ):
            main_module._server = None
            await main_module._cleanup_resources()
            mock_stop.assert_called_once()

    @pytest.mark.asyncio
    async def test_cleanup_disconnects_websocket_manager(self):
        """Cleanup should disconnect the WebSocket manager."""
        import ha_mcp.__main__ as main_module

        mock_manager = MagicMock()
        mock_manager.disconnect = AsyncMock()

        with (
            patch(
                "ha_mcp.client.websocket_listener.stop_websocket_listener", AsyncMock()
            ),
            patch("ha_mcp.client.websocket_client.websocket_manager", mock_manager),
        ):
            main_module._server = None
            await main_module._cleanup_resources()

            mock_manager.disconnect.assert_called_once()

    @pytest.mark.asyncio
    async def test_cleanup_closes_server(self):
        """Cleanup should close the server if it exists."""
        import ha_mcp.__main__ as main_module

        mock_server = MagicMock()
        mock_server.close = AsyncMock()
        main_module._server = mock_server

        with (
            patch(
                "ha_mcp.client.websocket_listener.stop_websocket_listener", AsyncMock()
            ),
            patch(
                "ha_mcp.client.websocket_client.websocket_manager",
                MagicMock(disconnect=AsyncMock()),
            ),
        ):
            await main_module._cleanup_resources()

            mock_server.close.assert_called_once()

        # Reset global state
        main_module._server = None

    @pytest.mark.asyncio
    async def test_cleanup_handles_exceptions_gracefully(self):
        """Cleanup should handle exceptions without crashing."""
        import ha_mcp.__main__ as main_module

        # Make everything raise exceptions
        with (
            patch(
                "ha_mcp.client.websocket_listener.stop_websocket_listener",
                AsyncMock(side_effect=Exception("test error")),
            ),
            patch(
                "ha_mcp.client.websocket_client.websocket_manager",
                MagicMock(disconnect=AsyncMock(side_effect=Exception("test error"))),
            ),
        ):
            main_module._server = None
            # Should not raise
            await main_module._cleanup_resources()


class TestShutdownTimeout:
    """Tests for shutdown timeout configuration."""

    def test_shutdown_timeout_is_two_seconds(self):
        """Shutdown timeout should be 2 seconds as per requirements."""
        import ha_mcp.__main__ as main_module

        assert main_module.SHUTDOWN_TIMEOUT_SECONDS == 2.0


class TestGracefulShutdownIntegration:
    """Integration tests for graceful shutdown behavior."""

    @pytest.mark.asyncio
    async def test_shutdown_event_cancels_server_task(self):
        """Setting shutdown event should cancel the server task."""
        import ha_mcp.__main__ as main_module

        # Create a mock MCP that runs forever
        mock_mcp = MagicMock()

        async def mock_run_async(show_banner=True):
            await asyncio.sleep(100)  # Simulate long-running server

        mock_mcp.run_async = mock_run_async

        with (
            patch.object(main_module, "_get_mcp", return_value=mock_mcp),
            patch.object(main_module, "_cleanup_resources", new_callable=AsyncMock),
        ):
            # Reset state
            main_module._shutdown_event = None
            main_module._shutdown_in_progress = False

            # Start the server in a task
            server_coro = main_module._run_with_graceful_shutdown()
            server_task = asyncio.create_task(server_coro)

            # Give it time to start
            await asyncio.sleep(0.1)

            # Trigger shutdown
            if main_module._shutdown_event:
                main_module._shutdown_event.set()

            # Wait for shutdown with timeout
            try:
                await asyncio.wait_for(server_task, timeout=3.0)
            except TimeoutError:
                pytest.fail("Server did not shut down within timeout")
            except asyncio.CancelledError:
                pass  # Expected

    @pytest.mark.asyncio
    async def test_cleanup_called_on_shutdown(self):
        """Resource cleanup should be called on shutdown."""
        import ha_mcp.__main__ as main_module

        cleanup_called = asyncio.Event()

        async def mock_cleanup():
            cleanup_called.set()

        mock_mcp = MagicMock()

        async def mock_run_async(show_banner=True):
            await asyncio.sleep(100)

        mock_mcp.run_async = mock_run_async

        with (
            patch.object(main_module, "_get_mcp", return_value=mock_mcp),
            patch.object(main_module, "_cleanup_resources", side_effect=mock_cleanup),
        ):
            main_module._shutdown_event = None
            main_module._shutdown_in_progress = False

            server_task = asyncio.create_task(main_module._run_with_graceful_shutdown())

            await asyncio.sleep(0.1)

            if main_module._shutdown_event:
                main_module._shutdown_event.set()

            try:
                await asyncio.wait_for(server_task, timeout=3.0)
            except (TimeoutError, asyncio.CancelledError):
                # Either outcome is acceptable here: the test only asserts that
                # cleanup ran (checked below), not how the task ultimately ended.
                pass

            # Verify cleanup was called
            assert cleanup_called.is_set(), "Cleanup was not called"


class TestStdinDetection:
    """Tests for stdin availability detection (Docker without -i flag)."""

    def test_stdin_available_when_tty(self):
        """Stdin should be available when connected to a tty."""
        import stat as stat_module

        import ha_mcp.__main__ as main_module

        with (
            patch("sys.stdin") as mock_stdin,
            patch("os.fstat") as mock_fstat,
            patch("os.isatty", return_value=True),
        ):
            mock_stdin.closed = False
            mock_stdin.fileno.return_value = 0
            mock_fstat.return_value = MagicMock(st_mode=stat_module.S_IFCHR)

            assert main_module._check_stdin_available() is True

    def test_stdin_available_when_pipe(self):
        """Stdin should be available when connected to a pipe (FIFO)."""
        import stat as stat_module

        import ha_mcp.__main__ as main_module

        with (
            patch("sys.stdin") as mock_stdin,
            patch("os.fstat") as mock_fstat,
            patch("os.isatty", return_value=False),
        ):
            mock_stdin.closed = False
            mock_stdin.fileno.return_value = 0
            mock_fstat.return_value = MagicMock(st_mode=stat_module.S_IFIFO)

            assert main_module._check_stdin_available() is True

    def test_stdin_available_when_regular_file(self):
        """Stdin should be available when connected to a regular file."""
        import stat as stat_module

        import ha_mcp.__main__ as main_module

        with (
            patch("sys.stdin") as mock_stdin,
            patch("os.fstat") as mock_fstat,
            patch("os.isatty", return_value=False),
        ):
            mock_stdin.closed = False
            mock_stdin.fileno.return_value = 0
            mock_fstat.return_value = MagicMock(st_mode=stat_module.S_IFREG)

            assert main_module._check_stdin_available() is True

    def test_stdin_not_available_when_closed(self):
        """Stdin should not be available when closed."""
        import ha_mcp.__main__ as main_module

        with patch("sys.stdin") as mock_stdin:
            mock_stdin.closed = True
            assert main_module._check_stdin_available() is False

    def test_stdin_not_available_when_none(self):
        """Stdin should not be available when None."""
        import ha_mcp.__main__ as main_module

        with patch("sys.stdin", None):
            assert main_module._check_stdin_available() is False

    def test_stdin_not_available_when_char_device_not_tty(self):
        """Stdin should not be available when char device but not tty (like /dev/null)."""
        import stat as stat_module

        import ha_mcp.__main__ as main_module

        with (
            patch("sys.stdin") as mock_stdin,
            patch("os.fstat") as mock_fstat,
            patch("os.isatty", return_value=False),
        ):
            mock_stdin.closed = False
            mock_stdin.fileno.return_value = 0
            mock_fstat.return_value = MagicMock(st_mode=stat_module.S_IFCHR)

            assert main_module._check_stdin_available() is False

    def test_stdin_not_available_when_fileno_raises(self):
        """Stdin should not be available when fileno() raises."""
        import ha_mcp.__main__ as main_module

        with patch("sys.stdin") as mock_stdin:
            mock_stdin.closed = False
            mock_stdin.fileno.side_effect = ValueError("no fileno")
            assert main_module._check_stdin_available() is False

    def test_stdin_not_available_when_fstat_raises(self):
        """Stdin should not be available when fstat() raises."""
        import ha_mcp.__main__ as main_module

        with (
            patch("sys.stdin") as mock_stdin,
            patch("os.fstat", side_effect=OSError("fstat failed")),
        ):
            mock_stdin.closed = False
            mock_stdin.fileno.return_value = 0

            assert main_module._check_stdin_available() is False

    def test_main_exits_when_stdin_not_available(self):
        """Main should exit with error when stdin is not available."""
        import ha_mcp.__main__ as main_module

        with (
            patch.object(sys, "argv", ["ha-mcp"]),
            patch.object(main_module, "_check_stdin_available", return_value=False),
            pytest.raises(SystemExit) as exc_info,
        ):
            main_module.main()

        assert exc_info.value.code == 1


class TestMainEntryPoint:
    """Tests for the main entry point function."""

    def test_smoke_test_flag_runs_smoke_test(self):
        """--smoke-test flag should run smoke test instead of server."""
        import ha_mcp.__main__ as main_module

        mock_smoke_test = MagicMock(return_value=0)

        with (
            patch.object(sys, "argv", ["ha-mcp", "--smoke-test"]),
            patch("ha_mcp.smoke_test.main", mock_smoke_test),
            pytest.raises(SystemExit) as exc_info,
        ):
            main_module.main()

        assert exc_info.value.code == 0
        mock_smoke_test.assert_called_once()

    def test_main_sets_up_signal_handlers(self):
        """Main should set up signal handlers before running."""
        import ha_mcp.__main__ as main_module

        setup_called = False

        def mock_setup():
            nonlocal setup_called
            setup_called = True

        # Reset global state to avoid test pollution
        main_module._shutdown_in_progress = False
        main_module._shutdown_event = None

        with (
            patch.dict(
                os.environ,
                {
                    "HOMEASSISTANT_URL": "http://test.local:8123",
                    "HOMEASSISTANT_TOKEN": "test_token",
                },
            ),
            patch.object(main_module, "_check_stdin_available", return_value=True),
            patch.object(main_module, "_setup_signal_handlers", side_effect=mock_setup),
            patch.object(
                main_module, "_run_with_graceful_shutdown", new_callable=AsyncMock
            ),
            pytest.raises(SystemExit),
        ):
            main_module.main()

        assert setup_called, "Signal handlers were not set up"


class TestHTTPEntryPoints:
    """Tests for HTTP entry points (main_web, main_sse)."""

    def test_main_web_uses_http_transport(self):
        """main_web should use http transport."""
        import ha_mcp.__main__ as main_module

        transport_used = None

        def mock_run_http(transport, default_port=8086):
            nonlocal transport_used
            transport_used = transport
            raise SystemExit(0)

        # Reset global state
        main_module._shutdown_in_progress = False
        main_module._shutdown_event = None

        # Provide credentials to pass validation
        with (
            patch.dict(
                os.environ,
                {
                    "HOMEASSISTANT_URL": "http://test.local:8123",
                    "HOMEASSISTANT_TOKEN": "test_token",
                },
            ),
            patch.object(main_module, "_run_http_server", side_effect=mock_run_http),
            pytest.raises(SystemExit),
        ):
            main_module.main_web()

        assert transport_used == "http"

    def test_main_sse_uses_sse_transport(self):
        """main_sse should use sse transport."""
        import ha_mcp.__main__ as main_module

        transport_used = None

        def mock_run_http(transport, default_port=8087):
            nonlocal transport_used
            transport_used = transport
            raise SystemExit(0)

        # Reset global state
        main_module._shutdown_in_progress = False
        main_module._shutdown_event = None

        # Provide credentials to pass validation
        with (
            patch.dict(
                os.environ,
                {
                    "HOMEASSISTANT_URL": "http://test.local:8123",
                    "HOMEASSISTANT_TOKEN": "test_token",
                },
            ),
            patch.object(main_module, "_run_http_server", side_effect=mock_run_http),
            pytest.raises(SystemExit),
        ):
            main_module.main_sse()

        assert transport_used == "sse"

    def test_http_runtime_uses_env_vars(self):
        """HTTP runtime should read host, port, and path from environment."""
        import ha_mcp.__main__ as main_module

        with patch.dict(
            os.environ,
            {"MCP_HOST": "127.0.0.1", "MCP_PORT": "9000", "MCP_SECRET_PATH": "/custom"},
        ):
            host, port, path = main_module._get_http_runtime()

        assert host == "127.0.0.1"
        assert port == 9000
        assert path == "/custom"

    def test_http_runtime_uses_defaults(self):
        """HTTP runtime should use defaults when env vars not set.

        Default host is 0.0.0.0 (preserves prior behavior — FastMCP's own
        default is 127.0.0.1, so the explicit fallback is load-bearing).
        """
        import ha_mcp.__main__ as main_module

        # Clear any existing env vars
        env = os.environ.copy()
        env.pop("MCP_HOST", None)
        env.pop("MCP_PORT", None)
        env.pop("MCP_SECRET_PATH", None)

        with patch.dict(os.environ, env, clear=True):
            host, port, path = main_module._get_http_runtime()

        assert host == "0.0.0.0"
        assert port == 8086
        assert path == "/mcp"

    def test_http_runtime_invalid_port_exits(self):
        """Non-integer MCP_PORT should cause sys.exit(1)."""
        import ha_mcp.__main__ as main_module

        with (
            patch.dict(os.environ, {"MCP_PORT": "not-a-number"}),
            pytest.raises(SystemExit) as exc_info,
        ):
            main_module._get_http_runtime()

        assert exc_info.value.code == 1

    def test_run_http_server_invokes_path_warning(self, monkeypatch):
        """HTTP/SSE startup must invoke _warn_if_default_path_exposed, so a
        future refactor that moves, reorders, or accidentally drops the
        call still fails CI. OAuth startup does not run this path, so the
        warning stays scoped to the standard-mode HTTP entrypoints."""
        import ha_mcp.__main__ as main_module

        called_with: list[tuple[str, int, str]] = []

        monkeypatch.setattr(
            main_module,
            "_warn_if_default_path_exposed",
            lambda host, port, path: called_with.append((host, port, path)),
        )
        monkeypatch.setattr(
            main_module,
            "_get_http_runtime",
            lambda default_port=8086: ("0.0.0.0", default_port, "/mcp"),
        )
        monkeypatch.setattr(main_module, "_get_mcp", object)
        monkeypatch.setattr(main_module, "_get_server", object)
        monkeypatch.setattr(
            main_module, "register_browser_landing", lambda *a, **kw: None
        )
        monkeypatch.setattr(
            "ha_mcp.settings_ui.register_settings_routes",
            lambda *a, **kw: None,
        )
        # Stub the async runner BEFORE _run_entrypoint so no real coroutine
        # is created — otherwise the un-awaited coroutine triggers a
        # PytestUnraisableExceptionWarning even though _run_entrypoint
        # itself is a no-op here.
        monkeypatch.setattr(
            main_module,
            "_run_http_with_graceful_shutdown",
            lambda *a, **kw: None,
        )
        monkeypatch.setattr(main_module, "_run_entrypoint", lambda *a, **kw: None)

        main_module._run_http_server("http", default_port=8086)

        assert called_with == [("0.0.0.0", 8086, "/mcp")]
