"""Tests for MCP config-file import (issue #5936).

Covers the round-trip-safe command join/split inverse (join_stdio_command ↔
parse_stdio_command, on both posix and win32 using the issue's Windows
fixtures), the pure config parser (parse_mcp_config), and the POST /import
route (stdio gate on/off, url dedup, one bad entry not sinking the batch).

Run from studio/backend:  python -m pytest tests/test_mcp_config_import.py -q
"""

import sys

import pytest

from core.inference import mcp_client
from core.inference.mcp_config_import import parse_mcp_config
from storage import mcp_servers_db


def _reset_db(tmp_path, monkeypatch):
    monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
    monkeypatch.setattr(mcp_servers_db, "_schema_ready", False)


def _enable(monkeypatch):
    monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1")


def _disable(monkeypatch):
    monkeypatch.delenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", raising = False)


# ── 1. join_stdio_command ↔ parse_stdio_command round-trip ──────────


@pytest.mark.parametrize(
    "parts",
    [
        ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
        ["python", "-m", "mod", "--name", "a b"],
        ["uvx", "some-server", "--flag"],
        ["/usr/local/bin/my-server"],
        ["mcp-server-sqlite"],
    ],
)
def test_join_parse_roundtrip_posix(monkeypatch, parts):
    monkeypatch.setattr(sys, "platform", "linux")
    joined = mcp_client.join_stdio_command(parts)
    assert mcp_client.parse_stdio_command(joined) == parts


@pytest.mark.parametrize(
    "parts",
    [
        # Issue #5936's literal Windows examples: absolute .exe with a path, and
        # backslash drive/dir args must survive the join→split round-trip intact.
        [
            "C:\\Users\\user\\Documents\\Office-Word-MCP-Server\\.venv\\Scripts\\python.exe",
            "C:\\Users\\user\\Documents\\Office-Word-MCP-Server\\word_mcp_server.py",
        ],
        [
            "node",
            "C:\\Users\\user\\Documents\\DesktopCommanderMCP\\dist\\index.js",
            "--no-onboarding",
        ],
        [
            "node",
            "C:\\Users\\user\\AppData\\Roaming\\npm\\node_modules\\@modelcontextprotocol\\server-filesystem\\dist\\index.js",
            "D:\\",
            "O:\\",
        ],
        # A command path with spaces is the case that actually needs quoting.
        ["C:\\Program Files\\node\\node.exe", "server.js"],
        ["C:\\Program Files\\Foo\\", "server.js"],
        ["C:\\Program Files\\Foo\\", '{"foo":"bar"}'],
        ["'C:\\Program Files\\node\\node.exe'", "server.js"],
        ["node", "O'Reilly"],
        ["node", "C:\\Users\\O'Reilly\\server.js"],
        ["node", "'draft'"],
        ["node", "'open", "close'"],
        ["node", ""],
    ],
)
def test_join_parse_roundtrip_win32(monkeypatch, parts):
    monkeypatch.setattr(sys, "platform", "win32")
    joined = mcp_client.join_stdio_command(parts)
    assert mcp_client.parse_stdio_command(joined) == parts


def test_parse_rejects_manual_single_quoted_windows_executable(monkeypatch):
    monkeypatch.setattr(sys, "platform", "win32")
    command = "'C:\\Program Files\\node\\node.exe' server.js"
    with pytest.raises(ValueError):
        mcp_client.parse_stdio_command(command)


def test_parse_windows_apostrophes_as_literals(monkeypatch):
    monkeypatch.setattr(sys, "platform", "win32")
    assert mcp_client.parse_stdio_command("node O'Reilly") == ["node", "O'Reilly"]
    assert mcp_client.parse_stdio_command("node C:\\Users\\O'Reilly\\server.js") == [
        "node",
        "C:\\Users\\O'Reilly\\server.js",
    ]
    assert mcp_client.parse_stdio_command("node 'draft'") == ["node", "'draft'"]
    assert mcp_client.parse_stdio_command("node 'open close'") == ["node", "'open", "close'"]


def test_parse_rejects_unterminated_windows_double_quote(monkeypatch):
    monkeypatch.setattr(sys, "platform", "win32")
    with pytest.raises(ValueError):
        mcp_client.parse_stdio_command('node "C:\\path with spaces')


# ── 2. parse_mcp_config ─────────────────────────────────────────────


def test_parse_stdio_entry():
    cfg = {
        "mcpServers": {
            "fs": {
                "command": "npx",
                "args": ["-y", "server", "/tmp"],
                "env": {"K": "v"},
            }
        }
    }
    entries, errors = parse_mcp_config(cfg)
    assert errors == []
    assert len(entries) == 1
    entry = entries[0]
    assert entry.display_name == "fs"
    assert entry.is_stdio is True
    assert entry.headers == {"K": "v"}
    assert mcp_client.parse_stdio_command(entry.url) == ["npx", "-y", "server", "/tmp"]


def test_parse_remote_entry():
    cfg = {
        "mcpServers": {
            "remote": {
                "url": "https://example.com/mcp",
                "headers": {"Authorization": "Bearer x"},
            }
        }
    }
    entries, errors = parse_mcp_config(cfg)
    assert errors == []
    assert entries[0].url == "https://example.com/mcp"
    assert entries[0].is_stdio is False
    assert entries[0].headers == {"Authorization": "Bearer x"}


def test_parse_preserves_disabled_and_oauth():
    cfg = {
        "servers": {
            "remote": {
                "type": "http",
                "url": "https://example.com/mcp",
                "oauth": {"clientId": "client"},
                "disabled": True,
            }
        }
    }
    entries, errors = parse_mcp_config(cfg)
    assert errors == []
    assert entries[0].is_enabled is False
    assert entries[0].use_oauth is True


def test_parse_accepts_cline_streamable_http_alias():
    cfg = {
        "mcpServers": {
            "remote": {
                "type": "streamableHttp",
                "url": "https://example.com/mcp",
            }
        }
    }
    entries, errors = parse_mcp_config(cfg)
    assert errors == []
    assert entries[0].url == "https://example.com/mcp"
    assert entries[0].is_stdio is False


@pytest.mark.parametrize(
    "server",
    [
        {"command": "node", "args": ["server.js"], "cwd": "/tmp/server"},
        {"command": "node", "args": ["server.js"], "envFile": ".env"},
        {"command": "node", "args": ["server.js"], "env": {"API_KEY": "${input:api-key}"}},
        {"command": "node", "args": ["${workspaceFolder}/server.js"]},
        {"command": "node", "args": ["server.js"], "env": {"HTTP_PROXY": None}},
        {"command": "node", "args": ["server.js"], "sandboxEnabled": True},
        {"url": "https://example.com/mcp", "headers": {"Authorization": "Bearer ${input:token}"}},
        {"url": "https://example.com/mcp", "headers": {"Authorization": None}},
        {"type": "http", "url": "https://example.com/sse"},
        {"type": "http", "url": "https://example.com/sse "},
        {"type": "streamableHttp", "url": "https://example.com/sse"},
        {"url": "https://example.com/mcp", "timeout": 120},
        {"url": "https://example.com/mcp", "timeoutMs": 120000},
        {"url": "https://example.com/mcp", "timeoutSeconds": 120},
        {"type": "sse", "url": "https://example.com/custom"},
    ],
)
def test_parse_rejects_unrepresentable_imports(server):
    entries, errors = parse_mcp_config({"servers": {"bad": server}})
    assert entries == []
    assert len(errors) == 1


def test_servers_alias_key():
    # VS Code uses "servers" instead of "mcpServers".
    cfg = {"servers": {"fs": {"command": "node", "args": ["x.js"]}}}
    entries, errors = parse_mcp_config(cfg)
    assert errors == []
    assert len(entries) == 1


def test_env_and_args_values_coerced_to_str():
    cfg = {"mcpServers": {"fs": {"command": "node", "args": [8080], "env": {"PORT": 8080}}}}
    entries, errors = parse_mcp_config(cfg)
    assert errors == []
    assert entries[0].headers == {"PORT": "8080"}
    assert mcp_client.parse_stdio_command(entries[0].url) == ["node", "8080"]


def test_args_optional():
    cfg = {"mcpServers": {"sqlite": {"command": "mcp-server-sqlite"}}}
    entries, errors = parse_mcp_config(cfg)
    assert errors == []
    assert entries[0].url == "mcp-server-sqlite"
    assert entries[0].headers is None


def test_bad_entry_does_not_sink_batch():
    cfg = {
        "mcpServers": {
            "good": {"command": "node", "args": ["x.js"]},
            "both": {"command": "node", "url": "https://x/mcp"},
            "neither": {"name": "oops"},
            "bad_args": {"command": "node", "args": "x.js"},
            "bad_env": {"command": "node", "env": ["NOT", "A", "DICT"]},
        }
    }
    entries, errors = parse_mcp_config(cfg)
    assert {e.display_name for e in entries} == {"good"}
    assert len(errors) == 4


def test_not_a_dict():
    entries, errors = parse_mcp_config([])
    assert entries == []
    assert len(errors) == 1


def test_missing_servers_key():
    entries, errors = parse_mcp_config({"foo": {}})
    assert entries == []
    assert len(errors) == 1


def test_servers_alias_error_names_actual_key():
    entries, errors = parse_mcp_config({"servers": []})
    assert entries == []
    assert errors == ["'servers' must be an object mapping name -> server."]


# ── 3. POST /import route ───────────────────────────────────────────


def test_import_route_creates_and_dedups(tmp_path, monkeypatch):
    import asyncio

    from models.mcp_servers import McpServerImportRequest
    import routes.mcp_servers as routes_mcp

    _reset_db(tmp_path, monkeypatch)
    _enable(monkeypatch)
    cfg = {
        "mcpServers": {
            "fs": {
                "command": "npx",
                "args": ["-y", "server", "/tmp"],
                "env": {"API_KEY": "sk"},
            },
            "remote": {"url": "https://example.com/mcp"},
            "oauth": {
                "type": "http",
                "url": "https://auth.example.com/mcp",
                "oauth": {"clientId": "client"},
            },
            "disabled": {
                "url": "https://disabled.example.com/mcp",
                "disabled": True,
            },
        }
    }
    res = asyncio.run(
        routes_mcp.import_mcp_servers(McpServerImportRequest(config = cfg), current_subject = "u")
    )
    assert res.errors == []
    assert res.skipped == []
    assert {c.display_name for c in res.created} == {"fs", "remote", "oauth", "disabled"}
    fs = next(c for c in res.created if c.display_name == "fs")
    assert fs.headers == {"API_KEY": "sk"}
    assert fs.use_oauth is False
    assert fs.is_enabled is True
    oauth = next(c for c in res.created if c.display_name == "oauth")
    assert oauth.use_oauth is True
    disabled = next(c for c in res.created if c.display_name == "disabled")
    assert disabled.is_enabled is False

    # Re-importing the same config skips both by url.
    res2 = asyncio.run(
        routes_mcp.import_mcp_servers(McpServerImportRequest(config = cfg), current_subject = "u")
    )
    assert res2.created == []
    assert set(res2.skipped) == {"fs", "remote", "oauth", "disabled"}


def test_import_route_gates_stdio_when_disabled(tmp_path, monkeypatch):
    import asyncio

    from models.mcp_servers import McpServerImportRequest
    import routes.mcp_servers as routes_mcp

    _reset_db(tmp_path, monkeypatch)
    _disable(monkeypatch)
    cfg = {
        "mcpServers": {
            "fs": {"command": "npx", "args": ["server"]},
            "remote": {"url": "https://example.com/mcp"},
        }
    }
    res = asyncio.run(
        routes_mcp.import_mcp_servers(McpServerImportRequest(config = cfg), current_subject = "u")
    )
    # Remote still imports; the stdio entry is rejected per-entry (gate off).
    assert {c.display_name for c in res.created} == {"remote"}
    assert any("fs" in err for err in res.errors)
    assert len(mcp_servers_db.list_servers()) == 1
