"""Tests for fastmcp list and fastmcp call CLI commands."""

import json
from pathlib import Path
from typing import Any
from unittest.mock import patch

import mcp.types
import pytest

from fastmcp import FastMCP
from fastmcp.cli import client as client_module
from fastmcp.cli.client import (
    Client,
    _build_client,
    _build_stdio_from_command,
    _format_call_result_text,
    _is_http_target,
    _sanitize_untrusted_text,
    call_command,
    coerce_value,
    format_tool_signature,
    list_command,
    parse_tool_arguments,
    resolve_server_spec,
)
from fastmcp.client.client import CallToolResult
from fastmcp.client.transports.stdio import StdioTransport

# ---------------------------------------------------------------------------
# coerce_value
# ---------------------------------------------------------------------------


class TestCoerceValue:
    def test_integer(self):
        assert coerce_value("42", {"type": "integer"}) == 42

    def test_integer_negative(self):
        assert coerce_value("-7", {"type": "integer"}) == -7

    def test_integer_invalid(self):
        with pytest.raises(ValueError, match="Expected integer"):
            coerce_value("abc", {"type": "integer"})

    def test_number(self):
        assert coerce_value("3.14", {"type": "number"}) == 3.14

    def test_number_integer_value(self):
        assert coerce_value("5", {"type": "number"}) == 5.0

    def test_number_invalid(self):
        with pytest.raises(ValueError, match="Expected number"):
            coerce_value("xyz", {"type": "number"})

    def test_boolean_true_variants(self):
        for val in ("true", "True", "TRUE", "1", "yes"):
            assert coerce_value(val, {"type": "boolean"}) is True

    def test_boolean_false_variants(self):
        for val in ("false", "False", "FALSE", "0", "no"):
            assert coerce_value(val, {"type": "boolean"}) is False

    def test_boolean_invalid(self):
        with pytest.raises(ValueError, match="Expected boolean"):
            coerce_value("maybe", {"type": "boolean"})

    def test_array(self):
        assert coerce_value("[1, 2, 3]", {"type": "array"}) == [1, 2, 3]

    def test_array_invalid(self):
        with pytest.raises(ValueError, match="Expected JSON array"):
            coerce_value("not-json", {"type": "array"})

    def test_object(self):
        assert coerce_value('{"a": 1}', {"type": "object"}) == {"a": 1}

    def test_string(self):
        assert coerce_value("hello", {"type": "string"}) == "hello"

    def test_string_default(self):
        """Unknown or missing type treats value as string."""
        assert coerce_value("hello", {}) == "hello"

    def test_string_preserves_numeric_looking_values(self):
        assert coerce_value("42", {"type": "string"}) == "42"


# ---------------------------------------------------------------------------
# parse_tool_arguments
# ---------------------------------------------------------------------------


class TestParseToolArguments:
    SCHEMA: dict[str, Any] = {
        "type": "object",
        "properties": {
            "query": {"type": "string"},
            "limit": {"type": "integer"},
            "verbose": {"type": "boolean"},
        },
        "required": ["query"],
    }

    def test_basic_key_value(self):
        result = parse_tool_arguments(("query=hello", "limit=10"), None, self.SCHEMA)
        assert result == {"query": "hello", "limit": 10}

    def test_input_json_only(self):
        result = parse_tool_arguments((), '{"query": "hello", "limit": 5}', self.SCHEMA)
        assert result == {"query": "hello", "limit": 5}

    def test_key_value_overrides_input_json(self):
        result = parse_tool_arguments(
            ("limit=20",), '{"query": "hello", "limit": 5}', self.SCHEMA
        )
        assert result == {"query": "hello", "limit": 20}

    def test_value_containing_equals(self):
        result = parse_tool_arguments(("query=a=b=c",), None, self.SCHEMA)
        assert result == {"query": "a=b=c"}

    def test_invalid_arg_format_exits(self):
        with pytest.raises(SystemExit):
            parse_tool_arguments(("noequalssign",), None, self.SCHEMA)

    def test_invalid_input_json_exits(self):
        with pytest.raises(SystemExit):
            parse_tool_arguments((), "not-valid-json", self.SCHEMA)

    def test_input_json_non_object_exits(self):
        with pytest.raises(SystemExit):
            parse_tool_arguments((), "[1,2,3]", self.SCHEMA)

    def test_single_json_object_as_positional(self):
        result = parse_tool_arguments(
            ('{"query": "hello", "limit": 5}',), None, self.SCHEMA
        )
        assert result == {"query": "hello", "limit": 5}

    def test_json_positional_ignored_when_input_json_set(self):
        """When --input-json is already provided, a JSON positional arg is not special."""
        with pytest.raises(SystemExit):
            parse_tool_arguments(('{"limit": 99}',), '{"query": "hello"}', self.SCHEMA)

    def test_coercion_error_exits(self):
        with pytest.raises(SystemExit):
            parse_tool_arguments(("limit=abc",), None, self.SCHEMA)


# ---------------------------------------------------------------------------
# format_tool_signature
# ---------------------------------------------------------------------------


class TestFormatToolSignature:
    def _make_tool(
        self,
        name: str = "my_tool",
        properties: dict[str, Any] | None = None,
        required: list[str] | None = None,
        output_schema: dict[str, Any] | None = None,
        description: str | None = None,
    ) -> mcp.types.Tool:
        input_schema: dict[str, Any] = {"type": "object"}
        if properties is not None:
            input_schema["properties"] = properties
        if required is not None:
            input_schema["required"] = required
        return mcp.types.Tool(
            name=name,
            description=description,
            inputSchema=input_schema,
            outputSchema=output_schema,
        )

    def test_no_params(self):
        tool = self._make_tool()
        assert format_tool_signature(tool) == "my_tool()"

    def test_required_param(self):
        tool = self._make_tool(
            properties={"query": {"type": "string"}},
            required=["query"],
        )
        assert format_tool_signature(tool) == "my_tool(query: str)"

    def test_optional_param_with_default(self):
        tool = self._make_tool(
            properties={"limit": {"type": "integer", "default": 10}},
        )
        assert format_tool_signature(tool) == "my_tool(limit: int = 10)"

    def test_optional_param_without_default(self):
        tool = self._make_tool(
            properties={"limit": {"type": "integer"}},
        )
        assert format_tool_signature(tool) == "my_tool(limit: int = ...)"

    def test_mixed_required_and_optional(self):
        tool = self._make_tool(
            properties={
                "query": {"type": "string"},
                "limit": {"type": "integer", "default": 10},
            },
            required=["query"],
        )
        sig = format_tool_signature(tool)
        assert sig == "my_tool(query: str, limit: int = 10)"

    def test_with_output_schema(self):
        tool = self._make_tool(
            properties={"q": {"type": "string"}},
            required=["q"],
            output_schema={"type": "object"},
        )
        assert format_tool_signature(tool) == "my_tool(q: str) -> dict"

    def test_anyof_type(self):
        tool = self._make_tool(
            properties={"value": {"anyOf": [{"type": "string"}, {"type": "integer"}]}},
            required=["value"],
        )
        assert format_tool_signature(tool) == "my_tool(value: str | int)"


# ---------------------------------------------------------------------------
# resolve_server_spec
# ---------------------------------------------------------------------------


class TestResolveServerSpec:
    def test_http_url(self):
        assert (
            resolve_server_spec("http://localhost:8000/mcp")
            == "http://localhost:8000/mcp"
        )

    def test_https_url(self):
        assert (
            resolve_server_spec("https://example.com/mcp") == "https://example.com/mcp"
        )

    def test_python_file_existing(self, tmp_path: Path):
        py_file = tmp_path / "server.py"
        py_file.write_text("# empty")
        result = resolve_server_spec(str(py_file))
        assert isinstance(result, StdioTransport)
        assert result.command == "fastmcp"
        assert result.args == ["run", str(py_file.resolve()), "--no-banner"]

    def test_json_mcp_config(self, tmp_path: Path):
        config_file = tmp_path / "mcp.json"
        config = {"mcpServers": {"test": {"url": "http://localhost:8000"}}}
        config_file.write_text(json.dumps(config))
        result = resolve_server_spec(str(config_file))
        assert isinstance(result, dict)
        assert "mcpServers" in result

    def test_json_fastmcp_config_exits(self, tmp_path: Path):
        config_file = tmp_path / "fastmcp.json"
        config_file.write_text(json.dumps({"source": {"type": "file"}}))
        with pytest.raises(SystemExit):
            resolve_server_spec(str(config_file))

    def test_json_not_found_exits(self, tmp_path: Path):
        with pytest.raises(SystemExit):
            resolve_server_spec(str(tmp_path / "nonexistent.json"))

    def test_directory_exits(self, tmp_path: Path):
        """Directories should not be treated as file paths."""
        with pytest.raises(SystemExit):
            resolve_server_spec(str(tmp_path))

    def test_unrecognised_exits(self):
        with pytest.raises(SystemExit):
            resolve_server_spec("some_random_thing")

    def test_command_returns_stdio_transport(self):
        result = resolve_server_spec(None, command="npx -y @mcp/server")
        assert isinstance(result, StdioTransport)
        assert result.command == "npx"
        assert result.args == ["-y", "@mcp/server"]

    def test_command_single_word(self):
        result = resolve_server_spec(None, command="myserver")
        assert isinstance(result, StdioTransport)
        assert result.command == "myserver"
        assert result.args == []

    def test_server_spec_and_command_exits(self):
        with pytest.raises(SystemExit):
            resolve_server_spec("http://localhost:8000", command="npx server")

    def test_neither_server_spec_nor_command_exits(self):
        with pytest.raises(SystemExit):
            resolve_server_spec(None)

    def test_transport_sse_rewrites_url(self):
        result = resolve_server_spec("http://localhost:8000/mcp", transport="sse")
        assert result == "http://localhost:8000/mcp/sse"

    def test_transport_sse_no_duplicate_suffix(self):
        result = resolve_server_spec("http://localhost:8000/sse", transport="sse")
        assert result == "http://localhost:8000/sse"

    def test_transport_sse_trailing_slash(self):
        result = resolve_server_spec("http://localhost:8000/mcp/", transport="sse")
        assert result == "http://localhost:8000/mcp/sse"

    def test_transport_http_leaves_url_unchanged(self):
        result = resolve_server_spec("http://localhost:8000/mcp", transport="http")
        assert result == "http://localhost:8000/mcp"


# ---------------------------------------------------------------------------
# _build_stdio_from_command
# ---------------------------------------------------------------------------


class TestBuildStdioFromCommand:
    def test_simple_command(self):
        transport = _build_stdio_from_command("uvx my-server")
        assert transport.command == "uvx"
        assert transport.args == ["my-server"]

    def test_quoted_args(self):
        transport = _build_stdio_from_command("npx -y '@scope/server'")
        assert transport.command == "npx"
        assert transport.args == ["-y", "@scope/server"]

    def test_empty_command_exits(self):
        with pytest.raises(SystemExit):
            _build_stdio_from_command("")

    def test_invalid_shell_syntax_exits(self):
        with pytest.raises(SystemExit):
            _build_stdio_from_command("npx 'unterminated")


# ---------------------------------------------------------------------------
# _is_http_target
# ---------------------------------------------------------------------------


class TestIsHttpTarget:
    def test_http_url(self):
        assert _is_http_target("http://localhost:8000") is True

    def test_https_url(self):
        assert _is_http_target("https://example.com/mcp") is True

    def test_file_path(self):
        assert _is_http_target("/path/to/server.py") is False

    def test_stdio_transport(self):
        assert _is_http_target(StdioTransport(command="npx", args=[])) is False

    def test_mcp_config_dict(self):
        """MCPConfig dicts are not HTTP targets — auth is per-server internally."""
        assert _is_http_target({"mcpServers": {}}) is False


# ---------------------------------------------------------------------------
# _build_client
# ---------------------------------------------------------------------------


class TestBuildClient:
    def test_http_target_gets_oauth_by_default(self):
        client = _build_client("http://localhost:8000/mcp")
        # OAuth is applied during Client init via _set_auth
        assert client.transport.auth is not None

    def test_stdio_target_no_auth(self):
        transport = StdioTransport(command="npx", args=["-y", "@mcp/server"])
        client = _build_client(transport)
        # Stdio transports don't support auth — no auth should be set
        assert not hasattr(client.transport, "auth") or client.transport.auth is None

    def test_explicit_auth_none_disables_oauth(self):
        client = _build_client("http://localhost:8000/mcp", auth="none")
        # "none" explicitly disables auth, even for HTTP targets
        assert client.transport.auth is None

    def test_mcp_config_no_auth(self):
        """MCPConfig dicts handle auth per-server; no top-level auth applied."""
        client = _build_client({"mcpServers": {"test": {"url": "http://localhost"}}})
        # MCPConfigTransport doesn't support _set_auth — no crash means success
        assert client.transport is not None


# ---------------------------------------------------------------------------
# Integration tests — invoke actual CLI commands via monkeypatched _build_client
# ---------------------------------------------------------------------------


def _build_test_server() -> FastMCP:
    """Create a minimal FastMCP server for integration tests."""
    server = FastMCP("TestServer")

    @server.tool
    def greet(name: str) -> str:
        """Say hello to someone."""
        return f"Hello, {name}!"

    @server.tool
    def add(a: int, b: int) -> int:
        """Add two numbers."""
        return a + b

    @server.resource("test://greeting")
    def greeting_resource() -> str:
        """A static greeting resource."""
        return "Hello from resource!"

    @server.prompt
    def ask(topic: str) -> str:
        """Ask about a topic."""
        return f"Tell me about {topic}"

    return server


@pytest.fixture()
def _patch_client():
    """Patch resolve_server_spec and _build_client so CLI commands use the
    in-process test server without needing a real transport."""
    server = _build_test_server()

    def fake_resolve(server_spec: Any, **kwargs: Any) -> str:
        return "fake"

    def fake_build_client(resolved: Any, **kwargs: Any) -> Client:
        return Client(server)

    with (
        patch.object(client_module, "resolve_server_spec", side_effect=fake_resolve),
        patch.object(client_module, "_build_client", side_effect=fake_build_client),
    ):
        yield


class TestListCommandCLI:
    @pytest.mark.usefixtures("_patch_client")
    async def test_list_tools(self, capsys: pytest.CaptureFixture[str]):
        await list_command("fake://server")
        captured = capsys.readouterr()
        assert "greet" in captured.out
        assert "add" in captured.out

    @pytest.mark.usefixtures("_patch_client")
    async def test_list_json(self, capsys: pytest.CaptureFixture[str]):
        await list_command("fake://server", json_output=True)
        captured = capsys.readouterr()
        data = json.loads(captured.out)
        names = {t["name"] for t in data["tools"]}
        assert "greet" in names
        assert "add" in names

    @pytest.mark.usefixtures("_patch_client")
    async def test_list_resources(self, capsys: pytest.CaptureFixture[str]):
        await list_command("fake://server", resources=True)
        captured = capsys.readouterr()
        assert "test://greeting" in captured.out

    @pytest.mark.usefixtures("_patch_client")
    async def test_list_prompts(self, capsys: pytest.CaptureFixture[str]):
        await list_command("fake://server", prompts=True)
        captured = capsys.readouterr()
        assert "ask" in captured.out


class TestCallCommandCLI:
    @pytest.mark.usefixtures("_patch_client")
    async def test_call_tool(self, capsys: pytest.CaptureFixture[str]):
        await call_command("fake://server", "greet", "name=World")
        captured = capsys.readouterr()
        assert "Hello, World!" in captured.out

    @pytest.mark.usefixtures("_patch_client")
    async def test_call_tool_json(self, capsys: pytest.CaptureFixture[str]):
        await call_command("fake://server", "greet", "name=World", json_output=True)
        captured = capsys.readouterr()
        data = json.loads(captured.out)
        assert data["is_error"] is False

    @pytest.mark.usefixtures("_patch_client")
    async def test_call_tool_not_found(self):
        with pytest.raises(SystemExit):
            await call_command("fake://server", "nonexistent")

    @pytest.mark.usefixtures("_patch_client")
    async def test_call_tool_missing_args(self):
        with pytest.raises(SystemExit):
            await call_command("fake://server", "greet")

    @pytest.mark.usefixtures("_patch_client")
    async def test_call_resource_by_uri(self, capsys: pytest.CaptureFixture[str]):
        await call_command("fake://server", "test://greeting")
        captured = capsys.readouterr()
        assert "Hello from resource!" in captured.out

    @pytest.mark.usefixtures("_patch_client")
    async def test_call_resource_json(self, capsys: pytest.CaptureFixture[str]):
        await call_command("fake://server", "test://greeting", json_output=True)
        captured = capsys.readouterr()
        data = json.loads(captured.out)
        assert isinstance(data, list)
        assert data[0]["text"] == "Hello from resource!"

    @pytest.mark.usefixtures("_patch_client")
    async def test_call_prompt(self, capsys: pytest.CaptureFixture[str]):
        await call_command("fake://server", "ask", "topic=Python", prompt=True)
        captured = capsys.readouterr()
        assert "Python" in captured.out

    @pytest.mark.usefixtures("_patch_client")
    async def test_call_prompt_json(self, capsys: pytest.CaptureFixture[str]):
        await call_command(
            "fake://server", "ask", "topic=Python", prompt=True, json_output=True
        )
        captured = capsys.readouterr()
        data = json.loads(captured.out)
        assert "messages" in data

    @pytest.mark.usefixtures("_patch_client")
    async def test_call_prompt_not_found(self):
        with pytest.raises(SystemExit):
            await call_command("fake://server", "nonexistent", prompt=True)

    async def test_call_missing_target(self):
        with pytest.raises(SystemExit):
            await call_command("fake://server", "")


# ---------------------------------------------------------------------------
# Structured content serialization
# ---------------------------------------------------------------------------


class TestFormatCallResult:
    def test_structured_content_uses_dict_not_data(
        self, capsys: pytest.CaptureFixture[str]
    ):
        """structured_content (raw dict) is used for display, not data (which may
        be a non-serializable dataclass)."""
        result = CallToolResult(
            content=[mcp.types.TextContent(type="text", text="ok")],
            structured_content={"key": "value"},
            meta=None,
            data=object(),  # non-serializable on purpose
            is_error=False,
        )
        # Should not raise — uses structured_content, not data
        _format_call_result_text(result)
        captured = capsys.readouterr()
        assert "value" in captured.out

    def test_escapes_rich_markup_and_control_chars(
        self, capsys: pytest.CaptureFixture[str]
    ):
        result = CallToolResult(
            content=[mcp.types.TextContent(type="text", text="[red]x[/red]\x1b[2J")],
            structured_content=None,
            meta=None,
            data=None,
            is_error=False,
        )

        _format_call_result_text(result)
        captured = capsys.readouterr()
        assert "[red]x[/red]" in captured.out
        assert "\\x1b" in captured.out
        assert "\x1b" not in captured.out


class TestSanitizeUntrustedText:
    def test_sanitize_untrusted_text(self):
        value = "[bold]hello[/bold]\x07"
        sanitized = _sanitize_untrusted_text(value)
        assert sanitized == "\\[bold]hello\\[/bold]\\x07"
