"""reload_all() — the single implementation behind reload_tools and the admin button.

The MCP half is the delicate one. MCP registrations are rebuilt from what the
servers answer *right now* — that is the only way a server removed from the
config, or a tool a server stopped exposing, ever leaves the registry. So the
rebuild happens in two steps: collect everything, then commit it in one swap. A
rebuild that fails, or a server that never answers, must leave the previous
registrations exactly as they were rather than an empty registry.
"""

import asyncio
import textwrap

import pytest

from navi.context_providers._loader import ContextProviderRegistry
from navi.core.registry import ToolRegistry
from navi.core.reload import ReloadReport, reload_all


def _write(path, name, version="v1", broken=False):
    if broken:
        path.write_text("not python (")
        return
    path.write_text(
        textwrap.dedent(
            f'''
            name = "{name}"
            description = "{version}"
            parameters = {{"type": "object", "properties": {{}}}}

            async def execute(params):
                return "{version}"
            '''
        ).lstrip()
    )


class FakeClient:
    def __init__(self, server, tools, silent=False):
        self.server = server
        self.connected = True
        self._tools = tools
        self.silent = silent

    async def list_tools(self):
        if self.silent:
            raise RuntimeError(f"no answer from {self.server}")
        return [
            type("T", (), {"name": n, "description": "d", "inputSchema": {}})
            for n in self._tools
        ]


class FakeMcpManager:
    """Reconnects to whatever servers the test put in ``servers``."""

    def __init__(self, servers: dict[str, list[str]]):
        self.servers = servers
        self.reloads = 0
        self.fail = False
        # Servers that stop answering: ``reload_all`` succeeds, listing them
        # does not — the shape of a server that is connected but silent.
        self.silent: set[str] = set()

    @property
    def clients(self):
        return {
            s: FakeClient(s, t, silent=s in self.silent)
            for s, t in self.servers.items()
        }

    async def reload_all(self):
        self.reloads += 1
        if self.fail:
            raise RuntimeError("mcp is down")


class HangingMcpManager:
    """A manager whose reload never returns — the shape of the prod hang."""

    clients: dict = {}

    async def reload_all(self):
        await asyncio.sleep(3600)


@pytest.fixture
def isolated_settings(tmp_path, monkeypatch):
    """Point tools_dir at a scratch directory and control enabled.json."""
    import navi.core.reload as reload_mod

    # Settings is frozen — replace the object the module sees, not the field.
    monkeypatch.setattr(
        reload_mod, "settings", reload_mod.settings.model_copy(update={"tools_dir": str(tmp_path)})
    )
    # enabled.json is read through tool_utils by both reload and the agent.
    monkeypatch.setattr("navi.core.tool_utils.load_user_enabled_tools", list)
    return tmp_path


class FakeBuiltin:
    name = "reload_tools"
    description = "d"
    parameters = {}


async def test_reports_what_was_loaded(isolated_settings):
    _write(isolated_settings / "alpha.py", "alpha")
    _write(isolated_settings / "beta.py", "beta")
    registry = ToolRegistry()
    registry.register(FakeBuiltin(), builtin=True)

    report = await reload_all(registry, None, None)

    assert report.ok is True
    assert sorted(report.tools) == ["alpha", "beta"]
    assert report.total_tools == 3
    assert report.mcp_error is None
    assert "Tools (2): alpha, beta." in report.lines()


async def test_enabled_json_naming_a_tool_that_does_not_exist_is_reported(isolated_settings, monkeypatch):
    """The gmail case: the name stayed in enabled.json after its file stopped
    loading, and nothing said so — the tool just vanished from every profile."""
    _write(isolated_settings / "alpha.py", "alpha")
    monkeypatch.setattr("navi.core.tool_utils.load_user_enabled_tools", lambda: ["ghost"])
    registry = ToolRegistry()

    report = await reload_all(registry, None, None)

    assert report.missing_enabled == ["ghost"]
    assert report.ok is True  # the reload itself worked; the config is wrong
    assert any("ghost" in line for line in report.lines())


async def test_one_broken_file_does_not_hide_the_rest(isolated_settings):
    _write(isolated_settings / "alpha.py", "alpha")
    _write(isolated_settings / "broken.py", "broken", broken=True)
    registry = ToolRegistry()

    report = await reload_all(registry, None, None)

    assert report.ok is False
    assert report.tools == ["alpha"]
    assert list(report.tool_errors) == ["broken.py"]
    assert registry.get("alpha") is not None


async def test_context_providers_are_reloaded(isolated_settings, tmp_path, monkeypatch):
    import navi.core.reload as reload_mod

    providers = tmp_path / "providers"
    providers.mkdir()
    (providers / "clock.py").write_text(
        textwrap.dedent(
            '''
            name = "clock"
            description = "the time"
            global_provider = False

            async def get_context():
                return "12:00"
            '''
        ).lstrip()
    )
    monkeypatch.setattr(
        reload_mod,
        "settings",
        reload_mod.settings.model_copy(update={"context_providers_dir": str(providers)}),
    )
    cp_registry = ContextProviderRegistry()

    report = await reload_all(ToolRegistry(), cp_registry, None)

    assert report.providers == ["clock"]
    assert cp_registry.get("clock") is not None


async def test_mcp_tools_of_a_removed_server_do_not_survive(isolated_settings):
    registry = ToolRegistry()
    manager = FakeMcpManager({"alpha": ["ping", "pong"]})

    first = await reload_all(registry, None, manager)
    assert first.mcp_tools == ["mcp__alpha__ping", "mcp__alpha__pong"]

    # The server now exposes only one tool...
    manager.servers = {"alpha": ["ping"]}
    second = await reload_all(registry, None, manager)
    assert second.mcp_tools == ["mcp__alpha__ping"]
    assert "mcp__alpha__pong" not in [t.name for t in registry.all()]

    # ...and then leaves the config entirely.
    manager.servers = {}
    third = await reload_all(registry, None, manager)
    assert third.mcp_tools == []
    assert registry.all() == []


async def test_builtin_tools_survive_an_mcp_reload(isolated_settings):
    registry = ToolRegistry()
    registry.register(FakeBuiltin(), builtin=True)

    await reload_all(registry, None, FakeMcpManager({"alpha": ["ping"]}))

    assert [t.name for t in registry.all()] == ["reload_tools", "mcp__alpha__ping"]


async def test_an_mcp_failure_is_isolated_and_reported(isolated_settings):
    """A dead MCP server must not erase what the tool pass already loaded."""
    _write(isolated_settings / "alpha.py", "alpha")
    registry = ToolRegistry()
    manager = FakeMcpManager({})
    manager.fail = True

    report = await reload_all(registry, None, manager)

    assert report.ok is False
    assert report.tools == ["alpha"]
    assert "mcp is down" in report.mcp_error
    assert registry.get("alpha") is not None


async def test_a_failed_mcp_pass_keeps_the_tools_it_had(isolated_settings):
    """The registry is committed in one step at the end of the rebuild, so a
    rebuild that dies halfway leaves what it had — not an empty registry with
    the model holding MCP names that resolve to nothing."""
    registry = ToolRegistry()
    manager = FakeMcpManager({"alpha": ["ping", "pong"]})
    await reload_all(registry, None, manager)

    manager.fail = True
    report = await reload_all(registry, None, manager)

    assert report.mcp_error is not None
    assert registry.get("mcp__alpha__ping") is not None
    assert registry.get("mcp__alpha__pong") is not None
    assert any("left untouched" in line for line in report.lines())


async def test_a_silent_server_cannot_hang_the_reload(isolated_settings, monkeypatch):
    """A server that never answers must cost the turn nothing: the phase is
    budgeted, and the report says so instead of the tool call hanging."""
    import navi.core.reload as reload_mod

    monkeypatch.setattr(reload_mod, "MCP_RELOAD_BUDGET_S", 0.2)
    registry = ToolRegistry()
    manager = FakeMcpManager({"alpha": ["ping"]})
    await reload_all(registry, None, manager)

    report = await asyncio.wait_for(
        reload_all(registry, None, HangingMcpManager()), timeout=5
    )

    assert report.ok is False
    assert "did not finish within" in report.mcp_error
    assert registry.get("mcp__alpha__ping") is not None
    assert any("left untouched" in line for line in report.lines())


async def test_the_tools_of_a_silent_server_do_not_take_the_others_down(isolated_settings):
    """One server that will not list its tools loses its own registrations for
    this round — the health check re-registers them when it answers again —
    while every other server keeps its own."""
    registry = ToolRegistry()
    manager = FakeMcpManager({"alpha": ["ping"], "beta": ["pong"]})
    await reload_all(registry, None, manager)

    manager.silent = {"alpha"}
    report = await reload_all(registry, None, manager)

    assert report.mcp_error is None
    assert report.mcp_tools == ["mcp__beta__pong"]
    assert registry.get("mcp__beta__pong") is not None
    assert [t.name for t in registry.all()] == ["mcp__beta__pong"]


def test_report_serialises_for_the_api():
    report = ReloadReport(tools=["a"], total_tools=2, tool_errors={"x.py": "boom"})
    data = report.as_dict()
    assert data["ok"] is False
    assert data["tools"] == ["a"] and data["total_tools"] == 2
    assert data["tool_errors"] == {"x.py": "boom"}
    assert set(data) == {
        "ok", "tools", "total_tools", "tool_errors", "missing_enabled",
        "providers", "provider_errors", "mcp_tools", "mcp_error",
    }
