"""Tests for ReloadConfigurationHandler."""

import os
import tempfile
from unittest.mock import Mock, patch

import pytest
import yaml

from mcp_hangar.application.commands.reload_handler import ReloadConfigurationHandler
from mcp_hangar.server.config import ServerConfigLoader
from mcp_hangar.application.commands import ReloadConfigurationCommand
from mcp_hangar.domain.events import ConfigurationReloaded, ConfigurationReloadFailed, ConfigurationReloadRequested
from mcp_hangar.domain.exceptions import ConfigurationError
from mcp_hangar.domain.model import McpServer


class TestReloadConfigurationHandler:
    """Tests for ReloadConfigurationHandler."""

    @pytest.fixture
    def mock_repository(self):
        """Create mock provider repository."""
        repo = Mock()
        repo.get_all.return_value = {}
        repo.get.return_value = None
        repo.remove.return_value = None
        repo.add.return_value = None
        return repo

    @pytest.fixture
    def mock_event_bus(self):
        """Create mock event bus."""
        bus = Mock()
        bus.publish.return_value = None
        return bus

    @pytest.fixture
    def handler(self, mock_repository, mock_event_bus):
        """Create handler instance.

        The real ServerConfigLoader, which is what bootstrap injects. These
        tests used to construct the handler without one and so exercised a
        "legacy path" fallback that production never took -- the tested path and
        the shipped path were different ones. The fallback is gone; this keeps
        the same assertions pointed at the path that ships.
        """
        return ReloadConfigurationHandler(mock_repository, mock_event_bus, config_loader=ServerConfigLoader())

    @pytest.fixture
    def temp_config_file(self):
        """Create temporary config file."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config = {
                "mcp_servers": {
                    "test-provider": {
                        "mode": "subprocess",
                        "command": ["python", "-m", "test_server"],
                        "idle_ttl_s": 300,
                    }
                }
            }
            yaml.dump(config, f)
            config_path = f.name

        yield config_path

        # Cleanup
        if os.path.exists(config_path):
            os.unlink(config_path)

    def test_reload_with_no_config_path_raises_error(self, handler):
        """Should raise ConfigurationError when no config path provided."""
        command = ReloadConfigurationCommand()

        with pytest.raises(ConfigurationError, match="No configuration path specified"):
            handler.handle(command)

    def test_reload_publishes_requested_event(self, handler, mock_event_bus, temp_config_file):
        """Should publish ConfigurationReloadRequested event."""
        command = ReloadConfigurationCommand(
            config_path=temp_config_file,
            requested_by="test",
        )

        with patch("mcp_hangar.server.config.load_config"):
            handler.handle(command)

        # Check first event published
        first_call = mock_event_bus.publish.call_args_list[0]
        event = first_call[0][0]
        assert isinstance(event, ConfigurationReloadRequested)
        assert event.config_path == temp_config_file
        assert event.requested_by == "test"

    def test_reload_with_invalid_config_publishes_failed_event(self, handler, mock_event_bus):
        """Should publish ConfigurationReloadFailed on invalid config."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            # Invalid YAML - missing providers section
            yaml.dump({"logging": {"level": "INFO"}}, f)
            invalid_config = f.name

        try:
            command = ReloadConfigurationCommand(config_path=invalid_config)

            with pytest.raises(ConfigurationError):
                handler.handle(command)

            # Check failed event published
            failed_events = [
                call[0][0]
                for call in mock_event_bus.publish.call_args_list
                if isinstance(call[0][0], ConfigurationReloadFailed)
            ]
            assert len(failed_events) == 1
            assert failed_events[0].config_path == invalid_config

        finally:
            os.unlink(invalid_config)

    def test_reload_detects_added_providers(self, handler, mock_repository, mock_event_bus, temp_config_file):
        """Should detect and add new providers."""
        # No existing providers
        mock_repository.get_all.return_value = {}

        command = ReloadConfigurationCommand(
            config_path=temp_config_file,
            graceful=True,
        )

        with patch("mcp_hangar.server.config.load_config"):
            result = handler.handle(command)

        assert result["success"] is True
        assert "test-provider" in result["mcp_servers_added"]
        assert len(result["mcp_servers_removed"]) == 0
        assert len(result["mcp_servers_updated"]) == 0

        # Check success event published
        success_events = [
            call[0][0]
            for call in mock_event_bus.publish.call_args_list
            if isinstance(call[0][0], ConfigurationReloaded)
        ]
        assert len(success_events) == 1
        assert "test-provider" in success_events[0].mcp_servers_added

    def test_reload_detects_removed_providers(self, handler, mock_repository, mock_event_bus):
        """Should detect and remove deleted providers."""
        # Create existing provider
        existing_provider = Mock(spec=McpServer)
        existing_provider.stop.return_value = None

        mock_repository.get_all.return_value = {"old-provider": existing_provider}
        mock_repository.get.return_value = existing_provider

        # Create config without old-provider
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config = {"mcp_servers": {}}
            yaml.dump(config, f)
            config_path = f.name

        try:
            command = ReloadConfigurationCommand(
                config_path=config_path,
                graceful=True,
            )

            with patch("mcp_hangar.server.config.load_config"):
                result = handler.handle(command)

            assert result["success"] is True
            assert "old-provider" in result["mcp_servers_removed"]
            existing_provider.shutdown.assert_called_once()
            existing_provider.stop.assert_not_called()
            mock_repository.remove.assert_called_once_with("old-provider")

        finally:
            os.unlink(config_path)

    def test_reload_detects_updated_providers(self, handler, mock_repository, mock_event_bus):
        """Should detect providers with changed configuration."""
        # Create existing provider with old config
        existing_provider = Mock(spec=McpServer)
        existing_provider.stop.return_value = None

        mock_repository.get_all.return_value = {"test-provider": existing_provider}
        mock_repository.get.return_value = existing_provider

        # Create config with modified provider
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config = {
                "mcp_servers": {
                    "test-provider": {
                        "mode": "subprocess",
                        "command": ["new", "command"],  # Changed
                        "idle_ttl_s": 300,
                    }
                }
            }
            yaml.dump(config, f)
            config_path = f.name

        try:
            command = ReloadConfigurationCommand(
                config_path=config_path,
                graceful=True,
            )

            with patch("mcp_hangar.server.config.load_config"):
                result = handler.handle(command)

            assert result["success"] is True
            assert "test-provider" in result["mcp_servers_updated"]
            existing_provider.shutdown.assert_called_once()
            existing_provider.stop.assert_not_called()

        finally:
            os.unlink(config_path)

    def test_reload_preserves_unchanged_providers(self, mock_event_bus):
        """Should neither restart nor replace a provider whose configuration is unchanged.

        Unchanged means the file declares it exactly as the running server was
        built. A reload used to put a fresh copy in the repository without
        stopping the running one, which orphaned its process (#1424).
        """
        from mcp_hangar.server.config import load_configuration
        from mcp_hangar.server.state import get_runtime

        repository = get_runtime().repository
        config = {
            "mcp_servers": {
                "test-provider": {
                    "mode": "subprocess",
                    "command": ["python", "-m", "test_server"],
                    "idle_ttl_s": 300,
                }
            }
        }
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            yaml.dump(config, f)
            config_path = f.name

        try:
            load_configuration(config_path)
            existing_provider = repository.get("test-provider")
            handler = ReloadConfigurationHandler(repository, mock_event_bus, config_loader=ServerConfigLoader())

            with patch.object(existing_provider, "shutdown") as shutdown:
                result = handler.handle(ReloadConfigurationCommand(config_path=config_path, graceful=True))

            assert result["success"] is True
            assert result["mcp_servers_unchanged"] == ["test-provider"]
            shutdown.assert_not_called()
            assert repository.get("test-provider") is existing_provider

        finally:
            os.unlink(config_path)
            if repository.exists("test-provider"):
                repository.remove("test-provider")

    def test_reload_uses_shutdown_for_removed_provider(self, handler, mock_repository, mock_event_bus):
        """Reload uses the supported shutdown lifecycle API."""
        # Create existing provider
        existing_provider = Mock(spec=McpServer)
        existing_provider.stop.return_value = None
        existing_provider.shutdown.return_value = None

        mock_repository.get_all.return_value = {"test-provider": existing_provider}
        mock_repository.get.return_value = existing_provider

        # Create empty config to remove provider
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            config = {"mcp_servers": {}}
            yaml.dump(config, f)
            config_path = f.name

        try:
            command = ReloadConfigurationCommand(
                config_path=config_path,
                graceful=False,
            )

            with patch("mcp_hangar.server.config.load_config"):
                handler.handle(command)

            existing_provider.shutdown.assert_called_once()
            existing_provider.stop.assert_not_called()

        finally:
            os.unlink(config_path)

    def test_reload_fails_when_existing_provider_cannot_shutdown(self, handler, mock_repository, mock_event_bus):
        """A failed shutdown must not be reported as a successful reload."""
        existing_provider = Mock(spec=McpServer)
        existing_provider.shutdown.side_effect = RuntimeError("process did not exit")

        mock_repository.get_all.return_value = {"old-provider": existing_provider}

        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            yaml.dump({"mcp_servers": {}}, f)
            config_path = f.name

        try:
            with patch("mcp_hangar.server.config.load_config") as load_config:
                with pytest.raises(ConfigurationError, match="Failed to stop mcp_server 'old-provider'"):
                    handler.handle(ReloadConfigurationCommand(config_path=config_path))

            existing_provider.shutdown.assert_called_once()
            mock_repository.remove.assert_not_called()
            load_config.assert_not_called()
            failure_events = [
                call.args[0]
                for call in mock_event_bus.publish.call_args_list
                if isinstance(call.args[0], ConfigurationReloadFailed)
            ]
            assert len(failure_events) == 1
        finally:
            os.unlink(config_path)


class TestReloadIsAnInputErrorNotAnOutage:
    """A bad config reloaded via the REST API is a 500, not a 503, and the
    response body does not carry the wrapped internal error text.

    Regression guard for #823: `(ConfigurationError, 503)` mapped every reload
    failure -- including an operator's typo -- to a retryable outage that
    load-balancers and health dashboards act on, and leaked the wrapped
    exception text (which includes the on-disk config path) to any
    authenticated caller.
    """

    def _handler(self) -> ReloadConfigurationHandler:
        repo = Mock()
        repo.get_all.return_value = {}
        return ReloadConfigurationHandler(repo, Mock(), config_loader=ServerConfigLoader())

    def _bad_config(self) -> str:
        # A config missing its `mcp_servers` section raises a ValueError that
        # names the on-disk path -- exactly the internal text that must not
        # reach the caller.
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            yaml.dump({"logging": {"level": "INFO"}}, f)
            return f.name

    def test_a_bad_config_reload_wraps_without_the_raw_reason(self) -> None:
        bad_config = self._bad_config()
        try:
            with pytest.raises(ConfigurationError) as excinfo:
                self._handler().handle(ReloadConfigurationCommand(config_path=bad_config))
        finally:
            os.unlink(bad_config)

        message = str(excinfo.value)
        assert bad_config not in message, "the on-disk config path must not reach the caller"
        assert "mcp_servers" not in message, "the raw underlying reason must not reach the caller"

    def test_the_generic_reload_failure_maps_to_500_not_503(self) -> None:
        # The wrapped generic ConfigurationError is an operator-input problem:
        # it must resolve to 500, never the 503 that #823 produced.
        from mcp_hangar.server.api.middleware import _get_status_code

        bad_config = self._bad_config()
        try:
            with pytest.raises(ConfigurationError) as excinfo:
                self._handler().handle(ReloadConfigurationCommand(config_path=bad_config))
        finally:
            os.unlink(bad_config)

        assert _get_status_code(excinfo.value) == 500

    async def test_the_error_envelope_leaks_nothing(self) -> None:
        # End to end through the middleware's error renderer: the body the caller
        # receives must not contain the config path or the raw reason.
        from mcp_hangar.server.api.middleware import _get_status_code, error_handler

        bad_config = self._bad_config()
        try:
            with pytest.raises(ConfigurationError) as excinfo:
                self._handler().handle(ReloadConfigurationCommand(config_path=bad_config))
        finally:
            os.unlink(bad_config)

        response = await error_handler(Mock(), excinfo.value)

        assert response.status_code == 500
        assert _get_status_code(excinfo.value) == 500
        body = response.body.decode()
        assert bad_config not in body
        assert "mcp_servers" not in body
