"""Regression tests for openjarvis.mcp.loader.load_mcp_tools_from_config.

Closes the gap that #461 surfaced — MCP tools were silently dropped on
`jarvis ask` and `jarvis serve` because neither path read
`config.tools.mcp.servers`. The loader is the shared helper they now
both call.
"""

from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

import pytest


def _make_mcp_cfg(*, enabled=True, servers):
    """Build a duck-typed MCPConfig with enabled flag + servers JSON."""
    cfg = MagicMock()
    cfg.enabled = enabled
    cfg.servers = json.dumps(servers) if not isinstance(servers, str) else servers
    return cfg


def _fake_tool(name):
    t = MagicMock()
    t.spec.name = name
    return t


@pytest.fixture
def _mock_mcp_stack():
    """Patch MCPClient / transports / MCPToolProvider so no real I/O happens."""
    with (
        patch("openjarvis.mcp.client.MCPClient") as MockClient,
        patch("openjarvis.mcp.transport.StreamableHTTPTransport") as MockHttp,
        patch("openjarvis.mcp.transport.StdioTransport") as MockStdio,
        patch("openjarvis.tools.mcp_adapter.MCPToolProvider") as MockProvider,
    ):
        # Default: any provider discovers no tools (per-test overrides as needed)
        MockProvider.return_value.discover.return_value = []
        MockClient.return_value.initialize.return_value = None
        yield {
            "client": MockClient,
            "http": MockHttp,
            "stdio": MockStdio,
            "provider": MockProvider,
        }


class TestLoaderEarlyReturns:
    def test_disabled_returns_empty(self, _mock_mcp_stack):
        from openjarvis.mcp.loader import load_mcp_tools_from_config

        cfg = _make_mcp_cfg(enabled=False, servers=[{"url": "http://x"}])
        tools, clients = load_mcp_tools_from_config(cfg)
        assert tools == []
        assert clients == []
        # Critical: when disabled, we don't even instantiate transports
        _mock_mcp_stack["http"].assert_not_called()

    def test_empty_servers_returns_empty(self, _mock_mcp_stack):
        from openjarvis.mcp.loader import load_mcp_tools_from_config

        cfg = _make_mcp_cfg(enabled=True, servers=[])
        tools, clients = load_mcp_tools_from_config(cfg)
        assert tools == []
        assert clients == []

    def test_malformed_json_logs_warning_returns_empty(self, _mock_mcp_stack, caplog):
        from openjarvis.mcp.loader import load_mcp_tools_from_config

        cfg = MagicMock()
        cfg.enabled = True
        cfg.servers = "{not valid json"
        with caplog.at_level("WARNING"):
            tools, clients = load_mcp_tools_from_config(cfg)
        assert tools == []
        assert clients == []
        assert any("parse MCP servers config" in r.message for r in caplog.records)


class TestLoaderTokenPlumbing:
    def test_token_passed_to_streamable_http(self, _mock_mcp_stack):
        """Regression for #461 — token in cfg → StreamableHTTPTransport(token=...)."""
        from openjarvis.mcp.loader import load_mcp_tools_from_config

        cfg = _make_mcp_cfg(
            enabled=True,
            servers=[
                {
                    "name": "home-assistant",
                    "url": "http://homeassistant.local:8123/mcp",
                    "token": "ha-llat-secret",
                }
            ],
        )
        load_mcp_tools_from_config(cfg)
        _mock_mcp_stack["http"].assert_called_once_with(
            url="http://homeassistant.local:8123/mcp",
            token="ha-llat-secret",
        )

    def test_no_token_passes_none(self, _mock_mcp_stack):
        """Missing token in cfg → token=None (not 'undefined' or KeyError)."""
        from openjarvis.mcp.loader import load_mcp_tools_from_config

        cfg = _make_mcp_cfg(
            enabled=True,
            servers=[{"name": "open", "url": "http://localhost:9583/mcp"}],
        )
        load_mcp_tools_from_config(cfg)
        _mock_mcp_stack["http"].assert_called_once_with(
            url="http://localhost:9583/mcp",
            token=None,
        )

    def test_stdio_server_does_not_get_token_kwarg(self, _mock_mcp_stack):
        """StdioTransport doesn't take a token (unix-socket auth is OOB)."""
        from openjarvis.mcp.loader import load_mcp_tools_from_config

        cfg = _make_mcp_cfg(
            enabled=True,
            servers=[
                {"name": "local-mcp", "command": "mcp-server-foo", "args": ["--flag"]}
            ],
        )
        load_mcp_tools_from_config(cfg)
        _mock_mcp_stack["stdio"].assert_called_once_with(
            command=["mcp-server-foo", "--flag"]
        )


class TestLoaderFiltering:
    def test_allowed_names_filter_applied(self, _mock_mcp_stack):
        """allowed_names limits the returned tools to that set."""
        from openjarvis.mcp.loader import load_mcp_tools_from_config

        _mock_mcp_stack["provider"].return_value.discover.return_value = [
            _fake_tool("alpha"),
            _fake_tool("beta"),
            _fake_tool("gamma"),
        ]
        cfg = _make_mcp_cfg(
            enabled=True,
            servers=[{"name": "x", "url": "http://x"}],
        )
        tools, _ = load_mcp_tools_from_config(cfg, allowed_names={"beta"})
        assert [t.spec.name for t in tools] == ["beta"]

    def test_include_tools_per_server(self, _mock_mcp_stack):
        """Per-server include_tools restricts to just those names."""
        from openjarvis.mcp.loader import load_mcp_tools_from_config

        _mock_mcp_stack["provider"].return_value.discover.return_value = [
            _fake_tool("alpha"),
            _fake_tool("beta"),
        ]
        cfg = _make_mcp_cfg(
            enabled=True,
            servers=[{"name": "x", "url": "http://x", "include_tools": ["alpha"]}],
        )
        tools, _ = load_mcp_tools_from_config(cfg)
        assert [t.spec.name for t in tools] == ["alpha"]

    def test_exclude_tools_per_server(self, _mock_mcp_stack):
        """Per-server exclude_tools drops the named tools."""
        from openjarvis.mcp.loader import load_mcp_tools_from_config

        _mock_mcp_stack["provider"].return_value.discover.return_value = [
            _fake_tool("alpha"),
            _fake_tool("beta"),
        ]
        cfg = _make_mcp_cfg(
            enabled=True,
            servers=[{"name": "x", "url": "http://x", "exclude_tools": ["alpha"]}],
        )
        tools, _ = load_mcp_tools_from_config(cfg)
        assert [t.spec.name for t in tools] == ["beta"]


class TestLoaderClientLifetime:
    def test_returns_live_clients_for_caller_to_hold(self, _mock_mcp_stack):
        """Critical lifetime contract — caller must hold `clients` so
        the transports' httpx sessions stay open. (#461 adversarial
        review caught this.) The list returned MUST contain a client
        per successfully-initialized server."""
        from openjarvis.mcp.loader import load_mcp_tools_from_config

        cfg = _make_mcp_cfg(
            enabled=True,
            servers=[
                {"name": "s1", "url": "http://x1"},
                {"name": "s2", "url": "http://x2"},
            ],
        )
        _, clients = load_mcp_tools_from_config(cfg)
        assert len(clients) == 2


class TestLoaderFailureIsolation:
    def test_one_server_failure_doesnt_abort_others(self, _mock_mcp_stack, caplog):
        """When one server's initialize() raises, the loader logs and
        moves on — the remaining servers still contribute tools."""
        from openjarvis.mcp.loader import load_mcp_tools_from_config

        # First initialize() raises, second succeeds
        bad_client = MagicMock()
        bad_client.initialize.side_effect = RuntimeError("can't reach server")
        good_client = MagicMock()
        good_client.initialize.return_value = None
        _mock_mcp_stack["client"].side_effect = [bad_client, good_client]
        _mock_mcp_stack["provider"].return_value.discover.return_value = [
            _fake_tool("survivor")
        ]

        cfg = _make_mcp_cfg(
            enabled=True,
            servers=[
                {"name": "broken", "url": "http://broken"},
                {"name": "working", "url": "http://working"},
            ],
        )
        with caplog.at_level("WARNING"):
            tools, clients = load_mcp_tools_from_config(cfg)
        assert [t.spec.name for t in tools] == ["survivor"]
        assert len(clients) == 1
        assert any("broken" in r.message for r in caplog.records)
