#!/usr/bin/env python3
"""
OpenHytale Wiki — MCP server (stdio).

Drop-in MCP server that exposes the OpenHytale dataset (647 Hytale server
commands + 21 catalogs of game data) to LLM agents such as Claude Desktop,
Codex, or any other MCP-compatible client.

Backend: fetches static JSON from https://openhytale.org/api/. Cached in memory
for the lifetime of the process (no daemon, no DB, no auth).

Stdlib only. No pip install required.

Install (Claude Desktop, claude_desktop_config.json):

  {
    "mcpServers": {
      "openhytale": {
        "command": "python3",
        "args": ["/absolute/path/to/server.py"]
      }
    }
  }

Tools exposed:
  - list_commands(filter?)            → list server commands (optionally filtered)
  - get_command(name)                 → full metadata for one command
  - search(query)                     → fuzzy search across commands + catalog entries
  - list_catalogs()                   → list the 21 catalogs with entry counts
  - find_in_catalog(catalog, query)   → fuzzy-find entries in a specific catalog
  - list_in_catalog(catalog, filter?) → list catalog entries (optionally filtered)
"""
import json
import os
import re
import sys
import urllib.error
import urllib.request

BASE_URL = os.environ.get("OPENHYTALE_API", "https://openhytale.org/api")
TIMEOUT = 10
USER_AGENT = "openhytale-mcp/0.1 (+https://openhytale.org/developers/)"

_CACHE: dict[str, object] = {}


def fetch_json(path: str):
    """GET a JSON resource under BASE_URL, cached for the process lifetime."""
    if path in _CACHE:
        return _CACHE[path]
    url = f"{BASE_URL}/{path.lstrip('/')}"
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
            data = json.loads(r.read().decode("utf-8"))
    except (urllib.error.URLError, json.JSONDecodeError) as e:
        raise RuntimeError(f"fetch failed for {url}: {e}") from e
    _CACHE[path] = data
    return data


_TOKEN_SPLIT = re.compile(r"[\s_\-./]+")


def fuzzy_find(items: list[str], query: str, limit: int = 15) -> list[str]:
    """Score items by how many query tokens appear as substrings (case-insensitive).
    Bonus when a token matches a whole token (split on _ - . / space)."""
    q_tokens = [t.lower() for t in _TOKEN_SPLIT.split(query.strip()) if t]
    if not q_tokens:
        return []
    scored = []
    for it in items:
        lower = it.lower()
        parts = set(_TOKEN_SPLIT.split(lower))
        score = 0
        for t in q_tokens:
            if t in parts:
                score += 3
            elif t in lower:
                score += 1
        if score:
            scored.append((score, it))
    scored.sort(key=lambda x: (-x[0], x[1]))
    return [it for _, it in scored[:limit]]


def slugify(name: str) -> str:
    s = name.lstrip("/")
    s = re.sub(r"[^\w-]", "-", s)
    return s.strip("-").lower()


TOOLS = [
    {
        "name": "list_commands",
        "description": (
            "List Hytale server commands. Optional filter is a substring matched "
            "case-insensitively against the command name (e.g. 'give', 'time'). "
            "Capped at 200 results."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "filter": {"type": "string", "description": "substring filter (optional)"},
            },
        },
    },
    {
        "name": "get_command",
        "description": (
            "Get the full metadata for one Hytale server command: description, "
            "permission, sub-commands, probe output, test status, Java class."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "command name, e.g. '/ambience' or '/give'"},
            },
            "required": ["name"],
        },
    },
    {
        "name": "search",
        "description": (
            "Fuzzy search across all 647 commands and 16,490 catalog entries. "
            "Returns the top 20 most relevant matches with type and URL."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "keywords in English"},
            },
            "required": ["query"],
        },
    },
    {
        "name": "list_catalogs",
        "description": (
            "List the 21 Hytale catalogs (items, NPCs, sounds, effects, prefabs, "
            "biomes, recipes, models, etc.) with the number of entries in each."
        ),
        "inputSchema": {"type": "object", "properties": {}},
    },
    {
        "name": "find_in_catalog",
        "description": (
            "Fuzzy search in a specific catalog. Example: catalog='items' query='iron bar' "
            "returns the top 15 items whose IDs match. Use list_catalogs to see catalog names."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "catalog": {"type": "string", "description": "catalog name (e.g. 'items', 'npcs', 'effects')"},
                "query": {"type": "string", "description": "keywords in English"},
            },
            "required": ["catalog", "query"],
        },
    },
    {
        "name": "list_in_catalog",
        "description": (
            "List a catalog's entries, optionally filtered by substring. Capped at 200."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "catalog": {"type": "string", "description": "catalog name"},
                "filter": {"type": "string", "description": "substring filter (optional)"},
            },
            "required": ["catalog"],
        },
    },
]


def send_rpc(obj):
    sys.stdout.write(json.dumps(obj) + "\n")
    sys.stdout.flush()


def text_result(mid, text, is_error=False):
    result = {"content": [{"type": "text", "text": text}]}
    if is_error:
        result["isError"] = True
    send_rpc({"jsonrpc": "2.0", "id": mid, "result": result})


def tool_list_commands(args: dict, mid):
    cmds = fetch_json("commands.json")
    f = (args.get("filter") or "").strip().lower()
    results = [c for c in cmds if f in c["name"].lower()] if f else cmds
    lines = []
    for c in results[:200]:
        desc = c.get("description") or ""
        short = (" — " + desc[:80] + ("…" if len(desc) > 80 else "")) if desc else ""
        lines.append(f"{c['name']:<40}{short}")
    header = f"{len(results)} commands" + (f" matching '{f}'" if f else "") + ":"
    text = header + "\n" + "\n".join(lines)
    if len(results) > 200:
        text += f"\n... ({len(results) - 200} more, refine the filter)"
    text_result(mid, text)


def tool_get_command(args: dict, mid):
    name = (args.get("name") or "").strip()
    if not name:
        return text_result(mid, "name is required", is_error=True)
    if not name.startswith("/"):
        name = "/" + name
    slug = slugify(name)
    try:
        data = fetch_json(f"commands/{slug}.json")
    except RuntimeError:
        return text_result(mid, f"command '{name}' not found", is_error=True)
    text_result(mid, json.dumps(data, indent=2, ensure_ascii=False))


def tool_search(args: dict, mid):
    q = (args.get("query") or "").strip()
    if not q:
        return text_result(mid, "query is required", is_error=True)
    index = fetch_json("search.json")
    names = [it["name"] for it in index]
    top = fuzzy_find(names, q, limit=20)
    by_name = {it["name"]: it for it in index}
    lines = []
    for n in top:
        it = by_name[n]
        kind = it["type"]
        url = it.get("url") or ""
        extra = f" ({it['catalog']})" if kind == "catalog_entry" else ""
        lines.append(f"[{kind}] {n}{extra}  → https://openhytale.org{url}")
    text = f"Top {len(top)} matches for '{q}':\n" + "\n".join(lines) if top else f"No match for '{q}'."
    text_result(mid, text)


def tool_list_catalogs(args: dict, mid):
    cats = fetch_json("catalogs.json")
    lines = [f"  {name:<22} {count:>5} entries" for name, count in sorted(cats.items())]
    text_result(mid, f"{len(cats)} catalogs:\n" + "\n".join(lines))


def _load_catalog(name: str) -> list[str]:
    try:
        data = fetch_json(f"catalogs/{name}.json")
    except RuntimeError as e:
        raise RuntimeError(f"unknown catalog '{name}' — use list_catalogs") from e
    if isinstance(data, dict):
        return list(data.keys())
    if isinstance(data, list):
        return [str(x) for x in data]
    return []


def tool_find_in_catalog(args: dict, mid):
    cat = (args.get("catalog") or "").strip().lower()
    q = (args.get("query") or "").strip()
    if not cat or not q:
        return text_result(mid, "catalog and query are required", is_error=True)
    try:
        ids = _load_catalog(cat)
    except RuntimeError as e:
        return text_result(mid, str(e), is_error=True)
    top = fuzzy_find(ids, q, limit=15)
    if not top:
        return text_result(mid, f"No match in '{cat}' for '{q}'.")
    text_result(mid, f"Top {len(top)} entries in '{cat}' for '{q}':\n" + "\n".join(top))


def tool_list_in_catalog(args: dict, mid):
    cat = (args.get("catalog") or "").strip().lower()
    if not cat:
        return text_result(mid, "catalog is required", is_error=True)
    try:
        ids = _load_catalog(cat)
    except RuntimeError as e:
        return text_result(mid, str(e), is_error=True)
    f = (args.get("filter") or "").strip().lower()
    results = [i for i in ids if f in i.lower()] if f else ids
    text = f"{len(results)} entries in '{cat}'" + (f" matching '{f}'" if f else "") + ":\n"
    text += "\n".join(results[:200])
    if len(results) > 200:
        text += f"\n... ({len(results) - 200} more, refine the filter)"
    text_result(mid, text)


DISPATCH = {
    "list_commands": tool_list_commands,
    "get_command": tool_get_command,
    "search": tool_search,
    "list_catalogs": tool_list_catalogs,
    "find_in_catalog": tool_find_in_catalog,
    "list_in_catalog": tool_list_in_catalog,
}


def handle(msg):
    mid = msg.get("id")
    method = msg.get("method")
    params = msg.get("params") or {}

    if method == "initialize":
        send_rpc({
            "jsonrpc": "2.0", "id": mid,
            "result": {
                "protocolVersion": params.get("protocolVersion", "2025-06-18"),
                "capabilities": {"tools": {}},
                "serverInfo": {"name": "openhytale", "version": "0.1.0"},
            },
        })
        return
    if method == "tools/list":
        send_rpc({"jsonrpc": "2.0", "id": mid, "result": {"tools": TOOLS}})
        return
    if method == "tools/call":
        name = params.get("name")
        args = params.get("arguments") or {}
        fn = DISPATCH.get(name)
        if fn is None:
            return text_result(mid, f"unknown tool: {name}", is_error=True)
        try:
            fn(args, mid)
        except Exception as e:
            text_result(mid, f"internal error in tool '{name}': {e}", is_error=True)
        return


def main():
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            msg = json.loads(line)
            handle(msg)
        except json.JSONDecodeError:
            pass


if __name__ == "__main__":
    main()
