#!/usr/bin/env python3
"""Blender MCP + skill 的环境体检与一键安装(跨平台: Windows / macOS / Linux)。

用法:
    python install_blender_mcp.py --check            # 只体检,什么都不改
    python install_blender_mcp.py --install           # 体检 + 安装(不启动 GUI)
    python install_blender_mcp.py --install --launch  # 装完顺便把 Blender 拉起来

注意:脚本不会去改 Blender 的偏好设置之外的任何东西;每一步都会打印做了什么。
mcp.json 会被备份成 mcp.json.bak-<时间戳>。
"""

import argparse
import glob
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path

HERE = Path(__file__).resolve().parent
SKILL_SRC = HERE / "skill" / "blender"
STARTUP_SRC = HERE / "startup" / "enable_asset_libraries.py"

MCP_ENTRY = {
    "type": "stdio",
    "enabled": True,
    "configured": True,
    "builtin": False,
    "timeout": 60000,
    "command": "uvx",
    "args": ["blender-mcp"],
}

OK, WARN, BAD = "[OK]  ", "[WARN]", "[BAD] "


def log(tag, msg):
    print("%s %s" % (tag, msg), flush=True)


def run(cmd, timeout=120, check=False):
    try:
        p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        if check and p.returncode != 0:
            raise RuntimeError(p.stderr.strip() or p.stdout.strip() or "exit %d" % p.returncode)
        return p
    except Exception as e:                                  # noqa: BLE001
        raise RuntimeError("%s -> %s" % (" ".join(cmd), e)) from e


def which(name):
    return shutil.which(name)


# ---------------------------------------------------------------- Blender


def find_blender(override=None):
    """返回 (blender 可执行文件路径, 版本字符串) 或 (None, None)。"""
    cands = []
    if override:
        cands.append(Path(override))
    exe = "blender.exe" if os.name == "nt" else "blender"
    p = which("blender")
    if p:
        cands.append(Path(p))
    if os.name == "nt":
        roots = [os.environ.get("ProgramFiles", r"C:\Program Files"),
                 os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
                 os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs"),
                 os.path.join(os.environ.get("APPDATA", ""), "Programs")]
        for r in roots:
            cands += [Path(x) for x in glob.glob(os.path.join(r, "Blender Foundation", "Blender *", exe))]
    else:
        for pat in ("/Applications/Blender.app/Contents/MacOS/Blender",
                    "~/Applications/Blender.app/Contents/MacOS/Blender",
                    "/Applications/Blender*/blender",
                    "/usr/local/bin/blender",
                    "/opt/homebrew/bin/blender"):
            cands += [Path(x).expanduser() for x in glob.glob(pat)]
    for c in cands:
        try:
            if c and c.exists() and c.is_file() and os.access(c, os.X_OK):
                return c, blender_version(c)
        except OSError:
            continue
    return None, None


def blender_version(exe):
    try:
        p = run([str(exe), "--version"], timeout=60)
        m = re.search(r"Blender\s+([0-9]+\.[0-9]+(?:\.[0-9]+)?)", p.stdout + p.stderr)
        return m.group(1) if m else "unknown"
    except Exception:                                        # noqa: BLE001
        return "unknown"


def user_scripts_dir(version):
    """Blender 的用户 scripts 目录(按版本号分目录)。"""
    if not version or version == "unknown":
        return None
    maj_min = ".".join(version.split(".")[:2])
    if os.name == "nt":
        base = Path(os.environ["APPDATA"]) / "Blender Foundation" / "Blender" / maj_min
    elif sys.platform == "darwin":
        base = Path.home() / "Library" / "Application Support" / "Blender" / maj_min
    else:
        base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "blender" / maj_min
    return base / "scripts"


# ---------------------------------------------------------------- 体检


def check(blender_override=None):
    print("=" * 64)
    print("  Blender MCP 环境体检")
    print("=" * 64)
    print("系统: %s %s (%s)" % (platform.system(), platform.release(), platform.machine()))
    print("")

    # 1. Blender
    blender, version = find_blender(blender_override)
    if blender:
        log(OK, "Blender: %s  (版本 %s)" % (blender, version))
    else:
        log(BAD, "Blender: 没找到。需要先装 Blender 5.1.x(https://www.blender.org/download/)")
        log("    ", "装完再跑一次本脚本。macOS 也一样。")
    scripts = user_scripts_dir(version) if blender else None

    # 2. 工具链
    uv = which("uv") or which("uvx")
    log(OK if uv else WARN, "uv/uvx: %s" % (uv or "没找到 — 装完 addon 需要它"))
    if not uv:
        log("    ", "Windows: winget install astral-sh.uv   |   pip install uv")
    node = which("node")
    log(OK if node else WARN, "Node.js: %s%s" % (node or "没找到", "" if node else " — 只有渲染管线需要它"))
    py = sys.executable
    log(OK, "Python: %s" % py)

    # 3. MCP 配置
    mcp_json = Path.home() / ".minimax" / "mcp.json"
    if mcp_json.exists():
        try:
            data = json.loads(mcp_json.read_text(encoding="utf-8"))
            servers = data.get("mcpServers") or data.get("servers") or {}
            entry = servers.get("blender")
            if entry:
                log(OK, "mcp.json 已有 blender 条目: enabled=%s command=%s"
                    % (entry.get("enabled"), entry.get("command")))
            else:
                log(WARN, "mcp.json 存在,但没有 blender 条目")
        except Exception as e:                              # noqa: BLE001
            log(WARN, "mcp.json 解析失败: %s" % e)
    else:
        log(WARN, "mcp.json 不存在: %s" % mcp_json)

    # 4. skill
    for dest in (Path.home() / ".minimax" / "skills" / "blender",
                 Path.home() / ".codex" / "skills" / "blender"):
        if (dest / "SKILL.md").exists():
            log(OK, "skill 已就位: %s" % dest)
        else:
            log(WARN, "skill 缺失: %s" % dest)

    # 5. Blender 插件
    if scripts:
        addons = scripts / "addons"
        found = sorted(p for p in addons.glob("blender*mcp*.py")) if addons.exists() else []
        if found:
            for f in found:
                log(OK, "add-on 文件: %s (%d KB)" % (f.name, f.stat().st_size // 1024))
        else:
            log(WARN, "add-on 未安装: %s" % addons)
        startup = scripts / "startup"
        log(OK if (startup / "enable_asset_libraries.py").exists() else WARN,
            "startup 脚本: %s" % (startup / "enable_asset_libraries.py"))
    else:
        log(WARN, "无法定位 Blender 的用户 scripts 目录")

    # 6. 端口
    try:
        import socket
        s = socket.socket()
        s.settimeout(0.5)
        busy = s.connect_ex(("127.0.0.1", 9876)) == 0
        s.close()
        log(WARN if busy else OK, "9876 端口: %s" % ("被占用(Blender 可能正在运行)" if busy else "空闲"))
    except Exception:                                       # noqa: BLE001
        pass
    print("")


# ---------------------------------------------------------------- 安装


def install_addon():
    if not which("uv") and not which("uvx"):
        raise RuntimeError("没有 uv/uvx,先装:winget install astral-sh.uv")
    log("...", "运行 uvx mcp-for-blender install-addon")
    run(["uvx", "mcp-for-blender", "install-addon"], timeout=300, check=True)
    log(OK, "add-on 文件已复制到 Blender 用户 addons 目录")


def enable_addon(blender_exe, version):
    """用 headless Blender 启用插件并保存 userpref(不开 GUI)。"""
    if not scripts_for(version):
        raise RuntimeError("定位不到 Blender 用户 scripts 目录")
    py = ("import bpy,sys\n"
          "m=bpy.context.preferences.addons\n"
          "ok=None\n"
          "for name in ('blender_mcp','blender-mcp-addon'):\n"
          "    try:\n"
          "        bpy.ops.preferences.addon_enable(module=name)\n"
          "        ok=name\n"
          "        break\n"
          "    except Exception as e:\n"
          "        print('enable fail',name,e)\n"
          "if ok is None:\n"
          "    print('ENABLE_FAILED')\n"
          "    sys.exit(3)\n"
          "bpy.ops.wm.save_userpref()\n"
          "print('ENABLED',ok)\n")
    log("...", "headless 启用插件并保存 userpref")
    p = run([str(blender_exe), "-b", "--python-expr", py], timeout=180)
    out = p.stdout + p.stderr
    if "ENABLED" not in out:
        raise RuntimeError("插件启用失败,Blender 输出:\n%s" % out[-1500:])
    log(OK, "插件已启用并写入 userpref")


def scripts_for(version):
    return user_scripts_dir(version)


def install_startup(version):
    d = scripts_for(version) / "startup"
    d.mkdir(parents=True, exist_ok=True)
    shutil.copy2(STARTUP_SRC, d / "enable_asset_libraries.py")
    log(OK, "startup 脚本 -> %s" % (d / "enable_asset_libraries.py"))


def install_skill():
    for base in (Path.home() / ".minimax" / "skills", Path.home() / ".codex" / "skills"):
        dest = base / "blender"
        dest.mkdir(parents=True, exist_ok=True)
        (dest / "scripts").mkdir(exist_ok=True)
        shutil.copy2(SKILL_SRC / "SKILL.md", dest / "SKILL.md")
        shutil.copy2(SKILL_SRC / "scripts" / "blender-render.js", dest / "scripts" / "blender-render.js")
        log(OK, "skill -> %s" % dest)


def patch_mcp_json():
    path = Path.home() / ".minimax" / "mcp.json"
    path.parent.mkdir(parents=True, exist_ok=True)
    if path.exists():
        bak = path.with_suffix(".json.bak-%s" % time.strftime("%Y%m%d%H%M%S"))
        shutil.copy2(path, bak)
        log(OK, "已备份 mcp.json -> %s" % bak.name)
        data = json.loads(path.read_text(encoding="utf-8"))
    else:
        data = {}
    key = "mcpServers" if "mcpServers" in data or "servers" not in data else "servers"
    data.setdefault(key, {})["blender"] = dict(MCP_ENTRY)
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    log(OK, "mcp.json 已注册 blender 服务器(uvx blender-mcp)")


def set_keys(blender_exe, version, polypizza_key=None, sketchfab_key=None):
    if not (polypizza_key or sketchfab_key):
        return
    py = ("import bpy\n"
          "p=bpy.context.preferences.addons['blender_mcp'].preferences\n"
          "%s"
          "bpy.ops.wm.save_userpref()\n"
          "print('KEYS_SET')\n")
    sets = ""
    if polypizza_key:
        sets += "p.polypizza_api_key=%r\n" % polypizza_key
    if sketchfab_key:
        sets += "p.sketchfab_api_key=%r\n" % sketchfab_key
    p = run([str(blender_exe), "-b", "--python-expr", py % sets], timeout=180)
    if "KEYS_SET" in (p.stdout + p.stderr):
        log(OK, "API key 已写入 add-on 偏好并保存 userpref")
    else:
        log(WARN, "写 key 失败,Blender 输出:\n%s" % (p.stdout + p.stderr)[-800:])


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--check", action="store_true", help="只体检")
    ap.add_argument("--install", action="store_true", help="体检并安装")
    ap.add_argument("--launch", action="store_true", help="装完启动 Blender GUI")
    ap.add_argument("--blender-path", default=None, help="手动指定 blender 可执行文件")
    ap.add_argument("--skip-addon", action="store_true", help="跳过装/启用插件")
    ap.add_argument("--polypizza-key", default=None)
    ap.add_argument("--sketchfab-key", default=None)
    args = ap.parse_args()

    check(args.blender_path)
    if not args.install:
        print("只做了体检,没有改动任何东西。加 --install 才会安装。")
        return

    blender, version = find_blender(args.blender_path)
    if not blender:
        raise SystemExit("找不到 Blender,先装 Blender 5.1.x 再来。")
    if not args.skip_addon:
        install_addon()
        enable_addon(blender, version)
    install_startup(version)
    install_skill()
    patch_mcp_json()
    set_keys(blender, version, args.polypizza_key, args.sketchfab_key)

    print("=" * 64)
    print("  安装完成")
    print("=" * 64)
    print("1) 启动 Blender:%s" % blender)
    print("   插件会自动起桥接,监听 127.0.0.1:9876")
    print("2) 在 MCode 里重启会话(让新的 MCP 服务器生效)")
    print("3) 验证:调用 blender 的 get_addon_status,应看到 up_to_date / protocol 13")
    if args.launch:
        print("4) 现在启动 Blender GUI...")
        subprocess.Popen([str(blender)])
    else:
        print("4) 想让我顺便把 Blender 拉起来,加 --launch(会弹窗口)")


if __name__ == "__main__":
    main()
