#!/usr/bin/env python3
"""
Native Messaging host for the personal AI assistant extension.

Protocol: Chrome Native Messaging over stdio.
Security posture:
- Filesystem tools require absolute paths.
- shell.exec is disabled unless the command is explicitly whitelisted.
- Output sizes are capped to avoid flooding the browser extension.
"""
import json
import os
import struct
import subprocess
import sys
from pathlib import Path


MAX_READ_BYTES = 2 * 1024 * 1024
MAX_OUTPUT_CHARS = 20000
DEFAULT_SHELL_ALLOWLIST = {"pwd", "ls", "date", "git", "node", "python3"}


def send_message(payload):
    raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    sys.stdout.buffer.write(struct.pack("<I", len(raw)))
    sys.stdout.buffer.write(raw)
    sys.stdout.buffer.flush()


def read_message():
    header = sys.stdin.buffer.read(4)
    if not header:
        return None
    length = struct.unpack("<I", header)[0]
    if length <= 0 or length > 10 * 1024 * 1024:
        raise ValueError("invalid message length")
    body = sys.stdin.buffer.read(length)
    return json.loads(body.decode("utf-8"))


def require_abs_path(value):
    path = Path(str(value or "")).expanduser()
    if not path.is_absolute():
        raise ValueError("path 必须是绝对路径")
    return path


def tool_fs_read(args):
    path = require_abs_path(args.get("path"))
    max_bytes = min(int(args.get("maxBytes") or MAX_READ_BYTES), MAX_READ_BYTES)
    data = path.read_bytes()[:max_bytes]
    return {
        "ok": True,
        "path": str(path),
        "content": data.decode(args.get("encoding") or "utf-8", errors="replace"),
        "truncated": path.stat().st_size > max_bytes,
    }


def tool_fs_write(args):
    path = require_abs_path(args.get("path"))
    content = str(args.get("content") or "")
    mode = "a" if args.get("append") else "w"
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open(mode, encoding=args.get("encoding") or "utf-8") as f:
        f.write(content)
    return {"ok": True, "path": str(path), "bytes": len(content.encode("utf-8"))}


def tool_fs_list(args):
    path = require_abs_path(args.get("path"))
    limit = min(int(args.get("limit") or 100), 500)
    items = []
    for item in sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))[:limit]:
        stat = item.stat()
        items.append({
            "name": item.name,
            "path": str(item),
            "type": "dir" if item.is_dir() else "file",
            "size": stat.st_size,
        })
    return {"ok": True, "path": str(path), "items": items}


def allowed_commands():
    raw = os.environ.get("AI_PM_AGENT_SHELL_ALLOWLIST", "")
    configured = {x.strip() for x in raw.split(",") if x.strip()}
    return configured or DEFAULT_SHELL_ALLOWLIST


def tool_shell_exec(args):
    command = args.get("command")
    if isinstance(command, str):
      command = [command]
    if not isinstance(command, list) or not command:
        raise ValueError("command 必须是数组，例如 ['git','status','--short']")
    executable = str(command[0])
    if executable not in allowed_commands():
        raise PermissionError(f"命令未在白名单内: {executable}")
    cwd = args.get("cwd")
    if cwd:
        cwd = str(require_abs_path(cwd))
    proc = subprocess.run(
        [str(x) for x in command],
        cwd=cwd,
        input=args.get("stdin"),
        text=True,
        capture_output=True,
        timeout=min(int(args.get("timeoutSeconds") or 20), 120),
        check=False,
    )
    stdout = (proc.stdout or "")[:MAX_OUTPUT_CHARS]
    stderr = (proc.stderr or "")[:MAX_OUTPUT_CHARS]
    return {"ok": proc.returncode == 0, "returncode": proc.returncode, "stdout": stdout, "stderr": stderr}


def tool_clipboard_read(_args):
    proc = subprocess.run(["pbpaste"], text=True, capture_output=True, check=False, timeout=5)
    return {"ok": proc.returncode == 0, "content": (proc.stdout or "")[:MAX_OUTPUT_CHARS], "stderr": proc.stderr}


def tool_clipboard_write(args):
    proc = subprocess.run(["pbcopy"], input=str(args.get("content") or ""), text=True, capture_output=True, check=False, timeout=5)
    return {"ok": proc.returncode == 0, "stderr": proc.stderr}


def tool_notify(args):
    title = str(args.get("title") or "个人AI助理")
    message = str(args.get("message") or "")
    script = f'display notification {json.dumps(message)} with title {json.dumps(title)}'
    proc = subprocess.run(["osascript", "-e", script], text=True, capture_output=True, check=False, timeout=5)
    return {"ok": proc.returncode == 0, "stderr": proc.stderr}


TOOLS = {
    "fs.read": tool_fs_read,
    "fs.write": tool_fs_write,
    "fs.list": tool_fs_list,
    "shell.exec": tool_shell_exec,
    "clipboard.read": tool_clipboard_read,
    "clipboard.write": tool_clipboard_write,
    "notify.send": tool_notify,
}


def dispatch(message):
    tool = message.get("tool")
    args = message.get("args") or {}
    if tool not in TOOLS:
        raise ValueError(f"未知工具: {tool}")
    return TOOLS[tool](args)


def main():
    while True:
        message = read_message()
        if message is None:
            return
        try:
            result = dispatch(message)
            send_message({"ok": True, "id": message.get("id"), "result": result})
        except Exception as exc:
            send_message({"ok": False, "id": message.get("id"), "error": str(exc), "errorType": exc.__class__.__name__})


if __name__ == "__main__":
    main()
