from __future__ import annotations

from typing import Any, cast
from unittest.mock import AsyncMock, patch

import httpx
import pytest
import respx

from tests.conftest import build_test_vibe_config
from tests.constants import CONNECTORS_BOOTSTRAP_PATH, MISTRAL_BASE_URL
from tests.stubs.fake_connector_registry import FakeConnectorRegistry
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
from vibe.core.config import ConnectorConfig, VibeConfig
from vibe.core.tools.base import BaseToolConfig, ToolError
from vibe.core.tools.connectors import compute_connector_counts
from vibe.core.tools.connectors.connector_registry import (
    ConnectorAuthAction,
    ConnectorRegistry,
    RemoteTool,
    _connector_error_message,
    _normalize_name,
    _unwrap_http_status_error,
    create_connector_proxy_tool_class,
)
from vibe.core.tools.manager import ToolManager
from vibe.core.tools.mcp.tools import MCPTool, MCPToolResult

# ---------------------------------------------------------------------------
# Unit tests for helper functions
# ---------------------------------------------------------------------------


class TestNormalizeName:
    def test_basic(self) -> None:
        assert _normalize_name("my_connector") == "my_connector"

    def test_special_chars(self) -> None:
        assert _normalize_name("my.connector!v2") == "my_connector_v2"

    def test_strip_edges(self) -> None:
        assert _normalize_name("--hello--") == "hello"


# ---------------------------------------------------------------------------
# Unit tests for proxy tool class creation
# ---------------------------------------------------------------------------


class TestCreateConnectorProxyTool:
    def test_tool_name(self) -> None:
        remote = RemoteTool(name="search", description="Search docs")
        cls = create_connector_proxy_tool_class(
            connector_name="deepwiki",
            connector_alias="deepwiki",
            connector_id="abc-123",
            remote=remote,
            api_key="key",
        )
        assert cls.get_name() == "connector_deepwiki_search"

    def test_is_connector_flag(self) -> None:
        remote = RemoteTool(name="read", description="Read file")
        cls = create_connector_proxy_tool_class(
            connector_name="myconn",
            connector_alias="myconn",
            connector_id="id-1",
            remote=remote,
            api_key="key",
        )
        assert issubclass(cls, MCPTool)
        assert cls._is_connector is True
        assert cls.is_connector() is True

    def test_description_includes_alias(self) -> None:
        remote = RemoteTool(name="fetch", description="Fetch page")
        cls = create_connector_proxy_tool_class(
            connector_name="web_tool",
            connector_alias="web_tool",
            connector_id="id-2",
            remote=remote,
            api_key="key",
        )
        assert cls.description.startswith("[web_tool]")
        assert "Fetch page" in cls.description

    def test_parameters_from_schema(self) -> None:
        schema = {
            "type": "object",
            "properties": {"query": {"type": "string"}},
            "required": ["query"],
        }
        remote = RemoteTool.model_validate({
            "name": "search",
            "description": "Search",
            "inputSchema": schema,
        })
        cls = create_connector_proxy_tool_class(
            connector_name="conn",
            connector_alias="conn",
            connector_id="id-3",
            remote=remote,
            api_key="key",
        )
        params = cls.get_parameters()
        assert params["properties"]["query"]["type"] == "string"

    def test_server_name_is_connector_alias(self) -> None:
        remote = RemoteTool(name="tool1", description="A tool")
        cls = cast(
            type[MCPTool],
            create_connector_proxy_tool_class(
                connector_name="my-connector",
                connector_alias="my-connector",
                connector_id="id-4",
                remote=remote,
                api_key="key",
            ),
        )
        assert cls.get_server_name() == "my-connector"


# ---------------------------------------------------------------------------
# FakeConnectorRegistry tests
# ---------------------------------------------------------------------------


class TestFakeConnectorRegistry:
    def test_get_tools(self) -> None:
        registry = FakeConnectorRegistry(
            connectors={
                "wiki": [RemoteTool(name="search", description="Search wiki")],
                "mail": [
                    RemoteTool(name="send", description="Send email"),
                    RemoteTool(name="read", description="Read email"),
                ],
            }
        )
        tools = registry.get_tools()
        assert "connector_wiki_search" in tools
        assert "connector_mail_send" in tools
        assert "connector_mail_read" in tools
        assert registry.connector_count == 2

    def test_connector_names(self) -> None:
        registry = FakeConnectorRegistry(connectors={"alpha": [], "beta": []})
        assert set(registry.get_connector_names()) == {"alpha", "beta"}


# ---------------------------------------------------------------------------
# Integration: ToolManager + ConnectorRegistry
# ---------------------------------------------------------------------------


class TestToolManagerConnectorIntegration:
    @staticmethod
    def _make_config(connectors: list[ConnectorConfig] | None = None) -> VibeConfig:
        return build_test_vibe_config(connectors=connectors or [])

    def test_connector_tools_registered(self) -> None:
        registry = FakeConnectorRegistry(
            connectors={"myconn": [RemoteTool(name="ping", description="Ping")]}
        )
        config = self._make_config()
        tm = ToolManager(
            config_getter=lambda: config,
            mcp_registry=FakeMCPRegistry(),
            connector_registry=registry,
        )
        assert "connector_myconn_ping" in tm.registered_tools

    def test_no_connector_registry(self) -> None:
        config = self._make_config()
        tm = ToolManager(
            config_getter=lambda: config,
            mcp_registry=FakeMCPRegistry(),
            connector_registry=None,
        )
        # No connector tools, but no crash
        connector_tools = [
            name
            for name, cls in tm.registered_tools.items()
            if issubclass(cls, MCPTool) and cls.is_connector()
        ]
        assert connector_tools == []


# ---------------------------------------------------------------------------
# Error message helpers
# ---------------------------------------------------------------------------


def _make_http_status_error(status: int, body: str = "") -> httpx.HTTPStatusError:
    response = httpx.Response(
        status, text=body, request=httpx.Request("POST", "http://example.com")
    )
    return httpx.HTTPStatusError("error", request=response.request, response=response)


class TestConnectorErrorMessage:
    def test_timeout(self) -> None:
        exc = httpx.ReadTimeout("timed out")
        msg = _connector_error_message(exc, "id-1", "myconn")
        assert "timed out" in msg.lower()
        assert "myconn" in msg

    def test_connect_error(self) -> None:
        exc = httpx.ConnectError("connection refused")
        msg = _connector_error_message(exc, "id-1", "myconn")
        assert "network" in msg.lower()
        assert "myconn" in msg

    def test_exception_group(self) -> None:
        exc = ExceptionGroup("errors", [ValueError("a"), RuntimeError("b")])
        msg = _connector_error_message(exc, "id-1", "myconn")
        assert "multiple errors" in msg.lower()
        assert "myconn" in msg

    def test_generic_error(self) -> None:
        exc = RuntimeError("something broke")
        msg = _connector_error_message(exc, "id-1", "myconn")
        assert "something broke" in msg
        assert "myconn" in msg

    def test_http_401_surfaces_auth_message(self) -> None:
        exc = _make_http_status_error(401, "Unauthorized")
        msg = _connector_error_message(exc, "id-1", "myconn")
        assert "authentication failed" in msg.lower()
        assert "401" in msg
        assert "Unauthorized" in msg

    def test_http_400_surfaces_response_body(self) -> None:
        exc = _make_http_status_error(400, '{"error": "missing field X"}')
        msg = _connector_error_message(exc, "id-1", "myconn")
        assert "400" in msg
        assert "missing field X" in msg

    def test_http_404_surfaces_not_found(self) -> None:
        exc = _make_http_status_error(404)
        msg = _connector_error_message(exc, "id-1", "myconn")
        assert "not found" in msg.lower()

    def test_http_error_wrapped_in_exception_group(self) -> None:
        inner = _make_http_status_error(400, "bad request detail")
        exc = ExceptionGroup("errors", [inner])
        msg = _connector_error_message(exc, "id-1", "myconn")
        assert "400" in msg
        assert "bad request detail" in msg


class TestUnwrapHttpStatusError:
    def test_direct(self) -> None:
        exc = _make_http_status_error(400)
        assert _unwrap_http_status_error(exc) is exc

    def test_from_exception_group(self) -> None:
        inner = _make_http_status_error(500)
        exc = ExceptionGroup("g", [ValueError("x"), inner])
        assert _unwrap_http_status_error(exc) is inner

    def test_from_cause(self) -> None:
        inner = _make_http_status_error(403)
        outer = RuntimeError("wrapper")
        outer.__cause__ = inner
        assert _unwrap_http_status_error(outer) is inner

    def test_none_when_absent(self) -> None:
        assert _unwrap_http_status_error(RuntimeError("no http")) is None


# ---------------------------------------------------------------------------
# ConnectorProxyTool.run() via MCP proxy
# ---------------------------------------------------------------------------


class TestConnectorProxyToolRun:
    @staticmethod
    def _make_tool_class() -> type[MCPTool]:
        remote = RemoteTool(name="search", description="Search docs")
        return cast(
            type[MCPTool],
            create_connector_proxy_tool_class(
                connector_name="wiki",
                connector_alias="wiki",
                connector_id="conn-123",
                remote=remote,
                api_key="test-key",
                server_url="https://custom.api.example.com",
            ),
        )

    @staticmethod
    def _make_tool(tool_cls: type[MCPTool]) -> MCPTool:
        return cast(MCPTool, tool_cls.from_config(lambda: BaseToolConfig()))

    @pytest.mark.asyncio
    async def test_run_calls_mcp_proxy(self) -> None:
        cls = self._make_tool_class()
        tool = self._make_tool(cls)
        expected = MCPToolResult(
            ok=True, server="test", tool="search", text="result text"
        )

        with patch(
            "vibe.core.tools.connectors.connector_registry.call_tool_http",
            new_callable=AsyncMock,
            return_value=expected,
        ) as mock_call:
            results = [r async for r in tool.invoke(query="hello")]

        mock_call.assert_awaited_once()
        call_args = mock_call.call_args
        assert "/v1/connectors-gateway/conn-123/mcp" in call_args.args[0]
        assert call_args.args[1] == "search"
        assert call_args.kwargs["headers"]["Authorization"] == "Bearer test-key"

        assert len(results) == 1
        assert results[0] == expected

    @pytest.mark.asyncio
    async def test_run_uses_default_base_url(self) -> None:
        remote = RemoteTool(name="ping", description="Ping")
        cls = cast(
            type[MCPTool],
            create_connector_proxy_tool_class(
                connector_name="svc",
                connector_alias="svc",
                connector_id="c-1",
                remote=remote,
                api_key="key",
                server_url=None,
            ),
        )
        tool = self._make_tool(cls)
        expected = MCPToolResult(ok=True, server="s", tool="ping", text="pong")

        with patch(
            "vibe.core.tools.connectors.connector_registry.call_tool_http",
            new_callable=AsyncMock,
            return_value=expected,
        ) as mock_call:
            [_ async for _ in tool.invoke()]

        url = mock_call.call_args.args[0]
        assert url.startswith("https://api.mistral.ai/")

    @pytest.mark.asyncio
    async def test_run_surfaces_timeout_error(self) -> None:
        cls = self._make_tool_class()
        tool = self._make_tool(cls)

        with (
            patch(
                "vibe.core.tools.connectors.connector_registry.call_tool_http",
                new_callable=AsyncMock,
                side_effect=httpx.ReadTimeout("timed out"),
            ),
            pytest.raises(ToolError, match="timed out"),
        ):
            [_ async for _ in tool.invoke(query="hello")]

    @pytest.mark.asyncio
    async def test_run_surfaces_connect_error(self) -> None:
        cls = self._make_tool_class()
        tool = self._make_tool(cls)

        with (
            patch(
                "vibe.core.tools.connectors.connector_registry.call_tool_http",
                new_callable=AsyncMock,
                side_effect=httpx.ConnectError("refused"),
            ),
            pytest.raises(ToolError, match="network"),
        ):
            [_ async for _ in tool.invoke(query="hello")]


# ---------------------------------------------------------------------------
# ToolManager: per-connector disabled / disabled_tools filtering
# ---------------------------------------------------------------------------


class TestConnectorDisableFiltering:
    @staticmethod
    def _make_config(connectors: list[ConnectorConfig] | None = None) -> VibeConfig:
        return build_test_vibe_config(connectors=connectors or [])

    def test_disabled_connector_excludes_all_tools(self) -> None:
        registry = FakeConnectorRegistry(
            connectors={
                "wiki": [
                    RemoteTool(name="search", description="Search"),
                    RemoteTool(name="read", description="Read"),
                ]
            }
        )
        config = self._make_config(
            connectors=[ConnectorConfig(name="wiki", disabled=True)]
        )
        tm = ToolManager(
            config_getter=lambda: config,
            mcp_registry=FakeMCPRegistry(),
            connector_registry=registry,
        )
        assert "connector_wiki_search" not in tm.available_tools
        assert "connector_wiki_read" not in tm.available_tools
        # But still registered (discoverable for UI)
        assert "connector_wiki_search" in tm.registered_tools

    def test_disabled_tools_filters_specific_tools(self) -> None:
        registry = FakeConnectorRegistry(
            connectors={
                "mail": [
                    RemoteTool(name="send", description="Send"),
                    RemoteTool(name="read", description="Read"),
                ]
            }
        )
        config = self._make_config(
            connectors=[ConnectorConfig(name="mail", disabled_tools=["send"])]
        )
        tm = ToolManager(
            config_getter=lambda: config,
            mcp_registry=FakeMCPRegistry(),
            connector_registry=registry,
        )
        assert "connector_mail_send" not in tm.available_tools
        assert "connector_mail_read" in tm.available_tools

    def test_no_config_means_all_disabled_by_default(self) -> None:
        registry = FakeConnectorRegistry(
            connectors={"wiki": [RemoteTool(name="search", description="Search")]}
        )
        config = self._make_config(connectors=[])
        tm = ToolManager(
            config_getter=lambda: config,
            mcp_registry=FakeMCPRegistry(),
            connector_registry=registry,
        )
        # Connectors without config entries are disabled by default
        assert "connector_wiki_search" not in tm.available_tools
        # But still registered (discoverable for UI)
        assert "connector_wiki_search" in tm.registered_tools

    def test_unrelated_config_does_not_affect_other_connectors(self) -> None:
        registry = FakeConnectorRegistry(
            connectors={
                "wiki": [RemoteTool(name="search", description="Search")],
                "mail": [RemoteTool(name="send", description="Send")],
            }
        )
        # Explicitly enable wiki, disable mail
        config = self._make_config(
            connectors=[
                ConnectorConfig(name="mail", disabled=True),
                ConnectorConfig(name="wiki", disabled=False),
            ]
        )
        tm = ToolManager(
            config_getter=lambda: config,
            mcp_registry=FakeMCPRegistry(),
            connector_registry=registry,
        )
        assert "connector_wiki_search" in tm.available_tools
        assert "connector_mail_send" not in tm.available_tools


# ---------------------------------------------------------------------------
# Bootstrap-based discovery (ConnectorRegistry._discover_all via httpx)
# ---------------------------------------------------------------------------

_BOOTSTRAP_URL = f"{MISTRAL_BASE_URL}{CONNECTORS_BOOTSTRAP_PATH}"


def _make_bootstrap_response(
    connectors: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
    return {"connectors": connectors or [], "errors": None}


def _make_connector_payload(
    *,
    connector_id: str = "conn-1",
    name: str = "wiki",
    is_ready: bool = True,
    tools: list[dict[str, Any]] | None = None,
    bootstrap_errors: list[str] | None = None,
    auth_action: dict[str, Any] | None = None,
) -> dict[str, Any]:
    return {
        "id": connector_id,
        "name": name,
        "display_name": name,
        "description": name,
        "status": {"is_ready": is_ready},
        "tools": tools or [],
        "bootstrap_errors": bootstrap_errors,
        "auth_action": auth_action,
    }


def _make_tool_payload(
    name: str = "search", description: str = "Search docs"
) -> dict[str, Any]:
    return {
        "name": name,
        "description": description,
        "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}},
    }


class TestBootstrapDiscovery:
    @respx.mock
    @pytest.mark.asyncio
    async def test_discovers_tools_from_bootstrap(self) -> None:
        payload = _make_bootstrap_response([
            _make_connector_payload(
                name="wiki",
                tools=[_make_tool_payload("search"), _make_tool_payload("read")],
            )
        ])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        tools = await registry.get_tools_async()

        assert "connector_wiki_search" in tools
        assert "connector_wiki_read" in tools
        assert registry.connector_count == 1
        assert registry.is_connected("wiki")

    @respx.mock
    @pytest.mark.asyncio
    async def test_skips_not_ready_connectors(self) -> None:
        payload = _make_bootstrap_response([
            _make_connector_payload(
                name="broken", is_ready=False, bootstrap_errors=["auth failed"]
            ),
            _make_connector_payload(name="healthy", tools=[_make_tool_payload("ping")]),
        ])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        tools = await registry.get_tools_async()

        assert "connector_healthy_ping" in tools
        assert not any("broken" in name for name in tools)
        assert not registry.is_connected("broken")
        assert registry.is_connected("healthy")

    @respx.mock
    @pytest.mark.asyncio
    async def test_handles_bootstrap_http_error(self) -> None:
        respx.get(_BOOTSTRAP_URL).mock(
            return_value=httpx.Response(500, text="Internal Server Error")
        )

        registry = ConnectorRegistry(api_key="test-key")
        tools = await registry.get_tools_async()

        assert tools == {}
        assert registry.connector_count == 0

    @respx.mock
    @pytest.mark.asyncio
    async def test_deduplicates_connector_aliases(self) -> None:
        payload = _make_bootstrap_response([
            _make_connector_payload(
                connector_id="c-1", name="mcp", tools=[_make_tool_payload("a")]
            ),
            _make_connector_payload(
                connector_id="c-2", name="mcp", tools=[_make_tool_payload("b")]
            ),
        ])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        tools = await registry.get_tools_async()

        assert "connector_mcp_a" in tools
        assert "connector_mcp_2_b" in tools
        assert registry.connector_count == 2

    @respx.mock
    @pytest.mark.asyncio
    async def test_skips_connector_without_id(self) -> None:
        payload = _make_bootstrap_response([
            {"name": "broken", "status": {"is_ready": True}, "tools": []},
            _make_connector_payload(name="valid", tools=[_make_tool_payload("ping")]),
        ])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        tools = await registry.get_tools_async()

        assert "connector_valid_ping" in tools
        assert registry.connector_count == 1

    @respx.mock
    @pytest.mark.asyncio
    async def test_empty_connectors_list(self) -> None:
        payload = _make_bootstrap_response([])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        tools = await registry.get_tools_async()

        assert tools == {}
        assert registry.connector_count == 0

    @respx.mock
    @pytest.mark.asyncio
    async def test_uses_custom_server_url(self) -> None:
        custom_url = "https://custom.api.example.com/v1/connectors/bootstrap"
        payload = _make_bootstrap_response([
            _make_connector_payload(tools=[_make_tool_payload("ping")])
        ])
        respx.get(custom_url).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(
            api_key="test-key", server_url="https://custom.api.example.com"
        )
        tools = await registry.get_tools_async()

        assert "connector_wiki_ping" in tools

    @respx.mock
    @pytest.mark.asyncio
    async def test_caches_after_first_call(self) -> None:
        payload = _make_bootstrap_response([
            _make_connector_payload(tools=[_make_tool_payload("search")])
        ])
        route = respx.get(_BOOTSTRAP_URL).mock(
            return_value=httpx.Response(200, json=payload)
        )

        registry = ConnectorRegistry(api_key="test-key")
        await registry.get_tools_async()
        await registry.get_tools_async()

        assert route.call_count == 1

    @respx.mock
    @pytest.mark.asyncio
    async def test_refresh_connector_updates_cache(self) -> None:
        payload = _make_bootstrap_response([
            _make_connector_payload(
                connector_id="c-1", name="wiki", tools=[_make_tool_payload("search")]
            )
        ])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        await registry.get_tools_async()

        # Refresh returns updated tools from a second bootstrap call
        refresh_payload = _make_bootstrap_response([
            _make_connector_payload(
                connector_id="c-1",
                name="wiki",
                tools=[_make_tool_payload("search"), _make_tool_payload("write")],
            )
        ])
        respx.get(_BOOTSTRAP_URL).mock(
            return_value=httpx.Response(200, json=refresh_payload)
        )

        refreshed = await registry.refresh_connector_async("wiki")
        assert "connector_wiki_search" in refreshed
        assert "connector_wiki_write" in refreshed


# ---------------------------------------------------------------------------
# Auth-actionable connector discovery
# ---------------------------------------------------------------------------


class TestAuthActionablediscovery:
    @respx.mock
    @pytest.mark.asyncio
    async def test_bootstrap_url_opts_into_auth_actionable_connectors(self) -> None:
        payload = _make_bootstrap_response([])
        route = respx.get(_BOOTSTRAP_URL).mock(
            return_value=httpx.Response(200, json=payload)
        )

        registry = ConnectorRegistry(api_key="test-key")
        await registry.get_tools_async()

        assert route.called
        called_url = str(route.calls.last.request.url)
        assert "include_auth_actionable_connectors=true" in called_url

    @respx.mock
    @pytest.mark.asyncio
    async def test_oauth_connector_is_discovered_but_disconnected(self) -> None:
        payload = _make_bootstrap_response([
            _make_connector_payload(
                name="linear", is_ready=False, tools=[], auth_action={"type": "oauth"}
            )
        ])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        tools = await registry.get_tools_async()

        assert tools == {}
        assert "linear" in registry.get_connector_names()
        assert not registry.is_connected("linear")
        assert registry.get_auth_action("linear") == ConnectorAuthAction.OAUTH

    @respx.mock
    @pytest.mark.asyncio
    async def test_credentials_setup_connector_is_discovered_but_disconnected(
        self,
    ) -> None:
        payload = _make_bootstrap_response([
            _make_connector_payload(
                name="custom_crm",
                is_ready=False,
                tools=[],
                auth_action={"type": "credentials_setup"},
            )
        ])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        await registry.get_tools_async()

        assert "custom_crm" in registry.get_connector_names()
        assert not registry.is_connected("custom_crm")
        assert (
            registry.get_auth_action("custom_crm")
            == ConnectorAuthAction.CREDENTIALS_SETUP
        )

    @respx.mock
    @pytest.mark.asyncio
    async def test_ready_connector_has_no_auth_action(self) -> None:
        payload = _make_bootstrap_response([
            _make_connector_payload(name="wiki", tools=[_make_tool_payload("search")])
        ])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        await registry.get_tools_async()

        assert registry.is_connected("wiki")
        assert registry.get_auth_action("wiki") == ConnectorAuthAction.NONE

    @respx.mock
    @pytest.mark.asyncio
    async def test_degraded_connector_has_no_auth_action(self) -> None:
        payload = _make_bootstrap_response([
            _make_connector_payload(
                name="degraded",
                is_ready=False,
                tools=[],
                bootstrap_errors=["tools_or_system_prompt_failed: timeout"],
                auth_action=None,
            )
        ])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        await registry.get_tools_async()

        assert "degraded" in registry.get_connector_names()
        assert not registry.is_connected("degraded")
        assert registry.get_auth_action("degraded") == ConnectorAuthAction.NONE

    @respx.mock
    @pytest.mark.asyncio
    async def test_unknown_auth_action_type_is_treated_as_none(self) -> None:
        payload = _make_bootstrap_response([
            _make_connector_payload(
                name="weird",
                is_ready=False,
                tools=[],
                auth_action={"type": "magic_link"},
            )
        ])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        await registry.get_tools_async()

        assert registry.get_auth_action("weird") == ConnectorAuthAction.NONE

    @respx.mock
    @pytest.mark.asyncio
    async def test_refresh_picks_up_oauth_completed(self) -> None:
        payload = _make_bootstrap_response([
            _make_connector_payload(
                connector_id="c-1",
                name="linear",
                is_ready=False,
                tools=[],
                auth_action={"type": "oauth"},
            )
        ])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        await registry.get_tools_async()
        assert registry.get_auth_action("linear") == ConnectorAuthAction.OAUTH

        refresh_payload = _make_bootstrap_response([
            _make_connector_payload(
                connector_id="c-1",
                name="linear",
                tools=[_make_tool_payload("search_issues")],
            )
        ])
        respx.get(_BOOTSTRAP_URL).mock(
            return_value=httpx.Response(200, json=refresh_payload)
        )

        refreshed = await registry.refresh_connector_async("linear")
        assert "connector_linear_search_issues" in refreshed
        assert registry.is_connected("linear")
        assert registry.get_auth_action("linear") == ConnectorAuthAction.NONE

    def test_get_auth_action_unknown_alias_returns_none(self) -> None:
        registry = ConnectorRegistry(api_key="test-key")
        assert registry.get_auth_action("nobody") == ConnectorAuthAction.NONE

    @respx.mock
    @pytest.mark.asyncio
    async def test_refresh_drops_connector_when_server_no_longer_lists_it(self) -> None:
        payload = _make_bootstrap_response([
            _make_connector_payload(
                connector_id="c-1",
                name="linear",
                is_ready=False,
                tools=[],
                auth_action={"type": "oauth"},
            )
        ])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        await registry.get_tools_async()
        assert "linear" in registry.get_connector_names()

        # Server now returns an empty connector list — the connector was
        # deleted or revoked. Local state must drop it entirely.
        respx.get(_BOOTSTRAP_URL).mock(
            return_value=httpx.Response(200, json=_make_bootstrap_response([]))
        )

        refreshed = await registry.refresh_connector_async("linear")

        assert refreshed == {}
        assert "linear" not in registry.get_connector_names()
        assert not registry.is_connected("linear")
        assert registry.get_auth_action("linear") == ConnectorAuthAction.NONE
        assert registry.get_connector_id("linear") is None

    @respx.mock
    @pytest.mark.asyncio
    async def test_refresh_keeps_cached_auth_action_when_fetch_fails(self) -> None:
        payload = _make_bootstrap_response([
            _make_connector_payload(
                connector_id="c-1",
                name="linear",
                is_ready=False,
                tools=[],
                auth_action={"type": "oauth"},
            )
        ])
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))

        registry = ConnectorRegistry(api_key="test-key")
        await registry.get_tools_async()
        assert registry.get_auth_action("linear") == ConnectorAuthAction.OAUTH

        # Bootstrap fails on refresh — cached state must survive.
        respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(500))

        refreshed = await registry.refresh_connector_async("linear")

        assert refreshed == {}
        assert "linear" in registry.get_connector_names()
        assert registry.get_auth_action("linear") == ConnectorAuthAction.OAUTH
        assert registry.get_connector_id("linear") == "c-1"


# ---------------------------------------------------------------------------
# Banner connector counts
# ---------------------------------------------------------------------------


class TestComputeConnectorCounts:
    def test_no_registry(self) -> None:
        assert compute_connector_counts(build_test_vibe_config(), None) == (0, 0)

    def test_empty_registry(self) -> None:
        registry = FakeConnectorRegistry()
        assert compute_connector_counts(build_test_vibe_config(), registry) == (0, 0)

    def test_no_config_entry_is_disabled_by_default(self) -> None:
        # Mirrors ToolManager: connectors without an explicit ConnectorConfig
        # entry are disabled (their tools are not registered).
        registry = FakeConnectorRegistry({
            "alpha": [RemoteTool(name="search")],
            "beta": [RemoteTool(name="list")],
        })
        assert compute_connector_counts(build_test_vibe_config(), registry) == (0, 2)

    def test_explicitly_enabled_and_connected(self) -> None:
        registry = FakeConnectorRegistry({
            "alpha": [RemoteTool(name="search")],
            "beta": [RemoteTool(name="list")],
        })
        config = build_test_vibe_config(
            connectors=[
                ConnectorConfig(name="alpha", disabled=False),
                ConnectorConfig(name="beta", disabled=False),
            ]
        )
        assert compute_connector_counts(config, registry) == (2, 2)

    def test_auth_pending_not_counted(self) -> None:
        # Empty tool list → FakeConnectorRegistry marks as not connected.
        registry = FakeConnectorRegistry(
            {"alpha": [RemoteTool(name="search")], "needs_auth": []},
            auth_actions={"needs_auth": ConnectorAuthAction.OAUTH},
        )
        config = build_test_vibe_config(
            connectors=[
                ConnectorConfig(name="alpha", disabled=False),
                ConnectorConfig(name="needs_auth", disabled=False),
            ]
        )
        assert compute_connector_counts(config, registry) == (1, 2)

    def test_disabled_not_counted(self) -> None:
        registry = FakeConnectorRegistry({
            "alpha": [RemoteTool(name="search")],
            "beta": [RemoteTool(name="list")],
        })
        config = build_test_vibe_config(
            connectors=[
                ConnectorConfig(name="alpha", disabled=False),
                ConnectorConfig(name="beta", disabled=True),
            ]
        )
        assert compute_connector_counts(config, registry) == (1, 2)

    def test_disabled_and_pending_combined(self) -> None:
        registry = FakeConnectorRegistry(
            {
                "alpha": [RemoteTool(name="search")],
                "beta": [RemoteTool(name="list")],
                "needs_auth": [],
            },
            auth_actions={"needs_auth": ConnectorAuthAction.OAUTH},
        )
        config = build_test_vibe_config(
            connectors=[
                ConnectorConfig(name="alpha", disabled=False),
                ConnectorConfig(name="beta", disabled=True),
                ConnectorConfig(name="needs_auth", disabled=False),
            ]
        )
        assert compute_connector_counts(config, registry) == (1, 3)
