#!/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 re
import shutil
import struct
import subprocess
import sys
import time
import uuid
from pathlib import Path


MAX_READ_BYTES = 2 * 1024 * 1024
MAX_OUTPUT_CHARS = 20000
DEFAULT_SHELL_ALLOWLIST = {"pwd", "ls", "date", "git", "node", "python3", "npm", "npx", "pnpm"}
PLUGIN_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_SKILLS_DIR = PLUGIN_ROOT / "skills"
CHECKPOINT_ROOT = PLUGIN_ROOT / ".agent_checkpoints"
SKIP_DIRS = {"node_modules", ".git", "dist", "build", "__pycache__", ".DS_Store"}


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 tool_fs_stat(args):
    path = require_abs_path(args.get("path"))
    stat = path.stat()
    return {
        "ok": True,
        "path": str(path),
        "name": path.name,
        "type": "dir" if path.is_dir() else "file",
        "size": stat.st_size,
        "modifiedAt": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(stat.st_mtime)),
        "exists": True,
    }


def is_probably_text(path):
    if path.suffix.lower() in {
        ".txt", ".md", ".json", ".csv", ".tsv", ".html", ".css", ".js", ".jsx", ".ts", ".tsx",
        ".py", ".yaml", ".yml", ".xml", ".sql", ".log", ".toml", ".ini"
    }:
        return True
    try:
        chunk = path.read_bytes()[:1024]
        return b"\x00" not in chunk
    except Exception:
        return False


def text_snippet(content, query, window=80):
    if not query:
        return content[: window * 2].replace("\n", " ")
    lower = content.lower()
    idx = lower.find(str(query).lower())
    if idx < 0:
        return content[: window * 2].replace("\n", " ")
    start = max(0, idx - window)
    end = min(len(content), idx + len(str(query)) + window)
    return content[start:end].replace("\n", " ")


def tool_fs_search(args):
    root = require_abs_path(args.get("path"))
    if not root.exists():
        raise FileNotFoundError(str(root))
    query = str(args.get("query") or "").strip()
    include_content = args.get("includeContent") is not False
    max_results = min(int(args.get("maxResults") or args.get("limit") or 50), 500)
    pattern = str(args.get("glob") or args.get("pattern") or "*").strip() or "*"
    max_bytes = min(int(args.get("maxBytes") or 512 * 1024), MAX_READ_BYTES)
    matches = []

    def consider(path):
        if len(matches) >= max_results:
            return
        name_hit = query and query.lower() in path.name.lower()
        glob_hit = path.match(pattern) or pattern == "*"
        content_hit = False
        snippet = ""
        if path.is_file() and include_content and is_probably_text(path):
            try:
                content = path.read_bytes()[:max_bytes].decode("utf-8", errors="replace")
                content_hit = bool(query and query.lower() in content.lower())
                if content_hit:
                    snippet = text_snippet(content, query)
            except Exception:
                content_hit = False
        if (query and (name_hit or content_hit)) or (not query and glob_hit):
            stat = path.stat()
            matches.append({
                "name": path.name,
                "path": str(path),
                "type": "dir" if path.is_dir() else "file",
                "size": stat.st_size,
                "nameMatch": bool(name_hit),
                "contentMatch": bool(content_hit),
                "snippet": snippet,
            })

    paths = [root] if root.is_file() else root.rglob("*")
    for path in paths:
        if len(matches) >= max_results:
            break
        if any(part in SKIP_DIRS for part in path.parts):
            continue
        if pattern != "*" and not path.match(pattern) and not (query and query.lower() in path.name.lower()):
            continue
        consider(path)
    return {"ok": True, "path": str(root), "query": query, "count": len(matches), "matches": matches}


def require_confirm(args, action):
    if args.get("dryRun") is False and args.get("confirm") is True:
        return None
    return {"ok": True, "dryRun": True, "action": action, "message": "这是预演结果；传入 confirm:true 且 dryRun:false 后才会执行。"}


def tool_fs_copy(args):
    src = require_abs_path(args.get("src") or args.get("source"))
    dst = require_abs_path(args.get("dst") or args.get("target"))
    preview = require_confirm(args, "copy")
    if preview:
        return {**preview, "src": str(src), "dst": str(dst)}
    dst.parent.mkdir(parents=True, exist_ok=True)
    if src.is_dir():
        shutil.copytree(src, dst, dirs_exist_ok=bool(args.get("overwrite")))
    else:
        shutil.copy2(src, dst)
    return {"ok": True, "src": str(src), "dst": str(dst), "copied": True}


def tool_fs_move(args):
    src = require_abs_path(args.get("src") or args.get("source"))
    dst = require_abs_path(args.get("dst") or args.get("target"))
    preview = require_confirm(args, "move")
    if preview:
        return {**preview, "src": str(src), "dst": str(dst)}
    dst.parent.mkdir(parents=True, exist_ok=True)
    if dst.exists() and not args.get("overwrite"):
        raise FileExistsError(str(dst))
    shutil.move(str(src), str(dst))
    return {"ok": True, "src": str(src), "dst": str(dst), "moved": True}


def tool_fs_delete(args):
    path = require_abs_path(args.get("path"))
    preview = require_confirm(args, "delete")
    if preview:
        return {**preview, "path": str(path)}
    if path.is_dir():
        shutil.rmtree(path)
    else:
        path.unlink()
    return {"ok": True, "path": str(path), "deleted": True}


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 safe_cloudflare_slug(value):
    raw = str(value or "aipros-html").strip().lower()
    chars = []
    last_dash = False
    for ch in raw:
        if "a" <= ch <= "z" or "0" <= ch <= "9":
            chars.append(ch)
            last_dash = False
        elif ch in {"-", "_", " ", "."} and not last_dash:
            chars.append("-")
            last_dash = True
    slug = "".join(chars).strip("-")
    return slug[:58] or f"aipros-html-{int(time.time())}"


def extract_public_urls(text):
    urls = []
    for match in re.findall(r"https://[^\s'\"<>]+", str(text or "")):
        url = match.rstrip(".,);]")
        if url not in urls:
            urls.append(url)
    pages_urls = [u for u in urls if ".pages.dev" in u]
    return pages_urls or urls


def run_wrangler(command, cwd, env, timeout=120):
    proc = subprocess.run(
        command,
        cwd=str(cwd),
        env=env,
        text=True,
        capture_output=True,
        timeout=min(int(timeout or 120), 120),
        check=False,
    )
    return {
        "ok": proc.returncode == 0,
        "returncode": proc.returncode,
        "stdout": (proc.stdout or "")[:MAX_OUTPUT_CHARS],
        "stderr": (proc.stderr or "")[:MAX_OUTPUT_CHARS],
    }


def tool_cloudflare_pages_deploy(args):
    content = str(args.get("content") or "")
    if not content.strip():
        raise ValueError("缺少 HTML content，无法发布")

    filename = safe_filename(args.get("filename") or "index.html")
    project_name = safe_cloudflare_slug(args.get("projectName") or Path(filename).stem)
    branch = safe_cloudflare_slug(args.get("branch") or "main")
    output_root = require_abs_path(args.get("outputDir")) if args.get("outputDir") else PLUGIN_ROOT / "outputs" / "cloudflare-pages"
    deploy_dir = output_root / f"{project_name}-{int(time.time())}-{uuid.uuid4().hex[:6]}"
    deploy_dir.mkdir(parents=True, exist_ok=True)

    index_path = deploy_dir / "index.html"
    index_path.write_text(content, encoding=args.get("encoding") or "utf-8")
    if filename.lower() != "index.html":
        (deploy_dir / filename).write_text(content, encoding=args.get("encoding") or "utf-8")

    env = os.environ.copy()
    env["CI"] = "1"
    token = args.get("cloudflareApiToken") or args.get("apiToken")
    account_id = args.get("cloudflareAccountId") or args.get("accountId")
    if token:
        env["CLOUDFLARE_API_TOKEN"] = str(token)
    if account_id:
        env["CLOUDFLARE_ACCOUNT_ID"] = str(account_id)

    base = ["npx", "--yes", "wrangler"]
    whoami = run_wrangler(base + ["whoami"], deploy_dir, env, timeout=60)
    if not whoami["ok"]:
        return {
            "ok": False,
            "stage": "auth",
            "error": "Cloudflare 未登录或 API Token 不可用。请先运行 npx wrangler login，或配置 CLOUDFLARE_API_TOKEN。",
            "deployDir": str(deploy_dir),
            "stdout": whoami["stdout"],
            "stderr": whoami["stderr"],
        }

    create = run_wrangler(
        base + ["pages", "project", "create", project_name, "--production-branch", branch],
        deploy_dir,
        env,
        timeout=120,
    )
    create_text = f"{create['stdout']}\n{create['stderr']}".lower()
    project_exists = "already exists" in create_text or "project already" in create_text or "name already" in create_text
    if not create["ok"] and not project_exists:
        return {
            "ok": False,
            "stage": "create",
            "error": "Cloudflare Pages 项目创建失败",
            "projectName": project_name,
            "deployDir": str(deploy_dir),
            "stdout": create["stdout"],
            "stderr": create["stderr"],
        }

    deploy = run_wrangler(
        base + ["pages", "deploy", str(deploy_dir), "--project-name", project_name, "--branch", branch],
        deploy_dir,
        env,
        timeout=120,
    )
    deploy_text = f"{deploy['stdout']}\n{deploy['stderr']}"
    urls = extract_public_urls(deploy_text)
    if not deploy["ok"]:
        return {
            "ok": False,
            "stage": "deploy",
            "error": "Cloudflare Pages 发布失败",
            "projectName": project_name,
            "deployDir": str(deploy_dir),
            "stdout": deploy["stdout"],
            "stderr": deploy["stderr"],
            "urls": urls,
        }

    return {
        "ok": True,
        "projectName": project_name,
        "branch": branch,
        "deployDir": str(deploy_dir),
        "indexPath": str(index_path),
        "url": urls[0] if urls else "",
        "urls": urls,
        "stdout": deploy["stdout"],
        "stderr": deploy["stderr"],
        "createdProject": create["ok"] and not project_exists,
        "deployedAt": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime()),
    }


def safe_project_slug(value):
    raw = str(value or "ai-frontend-project").strip().lower()
    chars = []
    last_dash = False
    for ch in raw:
        if ch.isalnum():
            chars.append(ch)
            last_dash = False
        elif ch in {"-", "_", " ", "."} and not last_dash:
            chars.append("-")
            last_dash = True
    slug = "".join(chars).strip("-")
    return slug[:60] or "ai-frontend-project"


def safe_filename(value):
    raw = str(value or "未命名Skill").strip()
    cleaned = re.sub(r'[\\/:*?"<>|]+', "_", raw).strip(" .")
    return cleaned[:80] or "未命名Skill"


def parse_frontmatter(content):
    text = str(content or "")
    if not text.startswith("---"):
        return {}, text
    match = re.match(r"^---\s*\n([\s\S]*?)\n---\s*\n?", text)
    if not match:
        return {}, text
    raw = match.group(1)
    data = {}
    for line in raw.splitlines():
        if ":" not in line:
            continue
        key, value = line.split(":", 1)
        data[key.strip()] = value.strip().strip('"').strip("'")
    return data, text[match.end():]


def default_skill_path(args):
    if args.get("path"):
        path = require_abs_path(args.get("path"))
        if path.is_dir():
            return path / "SKILL.md"
        return path
    skills_dir = Path(str(args.get("skillsDir") or DEFAULT_SKILLS_DIR)).expanduser()
    if not skills_dir.is_absolute():
        raise ValueError("skillsDir 必须是绝对路径")
    name = safe_filename(args.get("name"))
    return skills_dir / f"{name}.md"


def load_skill_content(args):
    if args.get("content") is not None:
        return str(args.get("content")), ""
    path = default_skill_path(args)
    return path.read_text(encoding=args.get("encoding") or "utf-8"), str(path)


def trigger_terms(description):
    terms = []
    for pattern in [r"“([^”]+)”", r'"([^"]+)"', r"'([^']+)'", r"`([^`]+)`"]:
        terms.extend(re.findall(pattern, description or ""))
    if not terms:
        for part in re.split(r"[、,，/ ]+", description or ""):
            part = part.strip()
            if 2 <= len(part) <= 24 and re.search(r"skill|技能|评测|生成|运行|文件|页面|接口|项目", part, re.I):
                terms.append(part)
    return list(dict.fromkeys(terms))


def validate_skill_content(content):
    frontmatter, body = parse_frontmatter(content)
    errors = []
    warnings = []
    score = 100
    name = frontmatter.get("name", "").strip()
    description = frontmatter.get("description", "").strip()
    if not frontmatter:
        errors.append("缺少 YAML frontmatter")
        score -= 25
    if not name:
        errors.append("frontmatter 缺少 name")
        score -= 20
    if not description:
        errors.append("frontmatter 缺少 description")
        score -= 20
    elif len(description) > 1024:
        warnings.append("description 超过 1024 字符，触发效率可能下降")
        score -= 8
    terms = trigger_terms(description)
    if len(terms) < 3:
        warnings.append("description 触发词偏少，建议给出至少 3 个真实用户说法")
        score -= 8
    if not re.search(r"^#\s+", body, re.M):
        warnings.append("正文缺少一级标题")
        score -= 8
    if not re.search(r"工作流|步骤|流程|Workflow|Process", body, re.I):
        warnings.append("正文缺少明确工作流")
        score -= 8
    if not re.search(r"输出|格式|Output", body, re.I):
        warnings.append("正文缺少输出格式说明")
        score -= 8
    line_count = len(str(content or "").splitlines())
    if line_count > 500:
        warnings.append("SKILL.md 超过 500 行，建议把长参考拆到 references/")
        score -= 6
    return {
        "ok": not errors,
        "frontmatter": frontmatter,
        "errors": errors,
        "warnings": warnings,
        "score": max(0, min(100, score)),
        "triggerTerms": terms,
        "lineCount": line_count,
    }


def tool_skill_validate(args):
    content, path = load_skill_content(args)
    result = validate_skill_content(content)
    result["path"] = path
    return result


def tool_skill_write(args):
    content = str(args.get("content") or "")
    if not content.strip():
        content = tool_skill_scaffold(args)["content"]
    validation = validate_skill_content(content)
    if validation["errors"] and not args.get("allowInvalid"):
        return {**validation, "ok": False, "error": "Skill 校验未通过，传入 allowInvalid:true 可强制写入"}
    path = default_skill_path({**args, "name": args.get("name") or validation["frontmatter"].get("name")})
    path.parent.mkdir(parents=True, exist_ok=True)
    if path.exists() and not args.get("overwrite"):
        raise FileExistsError(f"Skill 已存在: {path}")
    path.write_text(content, encoding=args.get("encoding") or "utf-8")
    return {"ok": True, "path": str(path), "bytes": len(content.encode("utf-8")), "validation": validation}


def tool_skill_read(args):
    content, path = load_skill_content(args)
    validation = validate_skill_content(content)
    return {"ok": True, "path": path, "content": content, "validation": validation}


def tool_skill_list(args):
    skills_dir = Path(str(args.get("skillsDir") or DEFAULT_SKILLS_DIR)).expanduser()
    if not skills_dir.is_absolute():
        raise ValueError("skillsDir 必须是绝对路径")
    items = []
    if not skills_dir.exists():
        return {"ok": True, "skillsDir": str(skills_dir), "skills": []}
    for path in sorted(skills_dir.glob("*.md"), key=lambda p: p.name.lower()):
        try:
            content = path.read_text(encoding="utf-8")
            frontmatter, _ = parse_frontmatter(content)
        except Exception:
            frontmatter = {}
        items.append({
            "name": frontmatter.get("name") or path.stem,
            "description": frontmatter.get("description") or "",
            "path": str(path),
            "filename": path.name,
        })
    for path in sorted(skills_dir.iterdir(), key=lambda p: p.name.lower()):
        skill_md = path / "SKILL.md"
        if path.is_dir() and skill_md.exists():
            content = skill_md.read_text(encoding="utf-8", errors="replace")
            frontmatter, _ = parse_frontmatter(content)
            items.append({
                "name": frontmatter.get("name") or path.name,
                "description": frontmatter.get("description") or "",
                "path": str(skill_md),
                "filename": f"{path.name}/SKILL.md",
            })
    return {"ok": True, "skillsDir": str(skills_dir), "count": len(items), "skills": items}


def prompt_hits(description, prompt):
    text = (description or "").lower()
    value = str(prompt or "").lower()
    terms = [t.lower() for t in trigger_terms(description)]
    return any(term and term in value for term in terms) or any(word in text and word in value for word in re.findall(r"[\w\u4e00-\u9fff]{2,}", text))


def tool_skill_evaluate(args):
    content, path = load_skill_content(args)
    validation = validate_skill_content(content)
    description = validation["frontmatter"].get("description", "")
    positives = args.get("positivePrompts") or []
    negatives = args.get("negativePrompts") or []
    positive_results = [{"prompt": p, "hit": prompt_hits(description, p)} for p in positives]
    negative_results = [{"prompt": p, "hit": prompt_hits(description, p)} for p in negatives]
    positive_rate = (sum(1 for x in positive_results if x["hit"]) / len(positive_results)) if positive_results else None
    false_positive_rate = (sum(1 for x in negative_results if x["hit"]) / len(negative_results)) if negative_results else None
    score = validation["score"]
    if positive_rate is not None:
        score = round(score * 0.7 + positive_rate * 30)
    if false_positive_rate is not None:
        score = round(score - false_positive_rate * 20)
    return {
        "ok": validation["ok"],
        "path": path,
        "score": max(0, min(100, score)),
        "validation": validation,
        "positiveHitRate": positive_rate,
        "negativeFalsePositiveRate": false_positive_rate,
        "positiveResults": positive_results,
        "negativeResults": negative_results,
    }


def collect_skill_paths(args):
    paths = []
    if args.get("paths"):
        for raw in args.get("paths") or []:
            path = require_abs_path(raw)
            if path.is_dir():
                skill_md = path / "SKILL.md"
                if skill_md.exists():
                    paths.append(skill_md)
            elif path.exists():
                paths.append(path)
    else:
        skills_dir = Path(str(args.get("skillsDir") or DEFAULT_SKILLS_DIR)).expanduser()
        if not skills_dir.is_absolute():
            raise ValueError("skillsDir 必须是绝对路径")
        if skills_dir.exists():
            paths.extend(sorted(skills_dir.glob("*.md"), key=lambda p: p.name.lower()))
            paths.extend(sorted((p / "SKILL.md" for p in skills_dir.iterdir() if p.is_dir() and (p / "SKILL.md").exists()), key=lambda p: str(p).lower()))
    unique = []
    seen = set()
    for path in paths:
        key = str(path.resolve())
        if key not in seen:
            seen.add(key)
            unique.append(path)
    return unique


def default_positive_prompts(name, terms):
    base_terms = terms[:3] or [name]
    return [f"帮我{term}" for term in base_terms if term][:5]


def default_negative_prompts():
    return ["帮我查天气", "生成采购合同", "打开邮箱看邮件"]


def tool_skill_benchmark(args):
    paths = collect_skill_paths(args)
    limit = min(int(args.get("limit") or 200), 500)
    min_score = int(args.get("minScore") or 70)
    positive_prompts = args.get("positivePrompts")
    negative_prompts = args.get("negativePrompts")
    items = []

    for path in paths[:limit]:
        try:
            content = path.read_text(encoding=args.get("encoding") or "utf-8", errors="replace")
            validation = validate_skill_content(content)
            frontmatter = validation.get("frontmatter") or {}
            name = frontmatter.get("name") or path.stem
            terms = validation.get("triggerTerms") or []
            eval_result = tool_skill_evaluate({
                "content": content,
                "positivePrompts": positive_prompts if positive_prompts is not None else default_positive_prompts(name, terms),
                "negativePrompts": negative_prompts if negative_prompts is not None else default_negative_prompts(),
            })
            score = int(eval_result.get("score") or validation.get("score") or 0)
            issues = []
            issues.extend(validation.get("errors") or [])
            issues.extend(validation.get("warnings") or [])
            if eval_result.get("positiveHitRate") is not None and eval_result.get("positiveHitRate") < 0.8:
                issues.append("正例触发命中率低于 80%")
            if eval_result.get("negativeFalsePositiveRate") is not None and eval_result.get("negativeFalsePositiveRate") > 0.2:
                issues.append("反例误触发率高于 20%")
            items.append({
                "name": name,
                "path": str(path),
                "score": score,
                "passed": score >= min_score and validation.get("ok") is True,
                "grade": "优秀" if score >= 85 else "合格" if score >= 70 else "需改进" if score >= 50 else "不合格",
                "positiveHitRate": eval_result.get("positiveHitRate"),
                "negativeFalsePositiveRate": eval_result.get("negativeFalsePositiveRate"),
                "triggerTerms": terms,
                "issues": issues,
            })
        except Exception as exc:
            items.append({
                "name": path.stem,
                "path": str(path),
                "score": 0,
                "passed": False,
                "grade": "不合格",
                "issues": [str(exc)],
            })

    items.sort(key=lambda item: (-int(item.get("score") or 0), item.get("name") or ""))
    count = len(items)
    passed = sum(1 for item in items if item.get("passed"))
    failed = count - passed
    avg_score = round(sum(int(item.get("score") or 0) for item in items) / count, 1) if count else 0
    report = {
        "ok": True,
        "count": count,
        "passed": passed,
        "failed": failed,
        "minScore": min_score,
        "avgScore": avg_score,
        "items": items,
    }

    output_path = args.get("outputPath")
    if output_path:
        path = require_abs_path(output_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
        report["outputPath"] = str(path)
    return report


def tool_skill_scaffold(args):
    name = str(args.get("name") or "新Skill").strip()
    purpose = str(args.get("purpose") or args.get("goal") or "完成指定任务").strip()
    triggers = args.get("triggers") or [name, purpose]
    triggers_text = "”“".join([str(x).strip() for x in triggers if str(x).strip()][:6])
    content = f"""---
name: "{name}"
description: "当用户说“{triggers_text}”时使用。用于{purpose}。"
---

# {name}

## 角色
你是{name}专家，负责{purpose}。

## 信息收集规则
- 先判断用户是否提供了任务对象、目标、材料和输出用途。
- 信息不足时最多追问 3 个关键问题。
- 用户明确要求先出草稿时，基于已有信息执行并标注“待补充”。

## 可用工具
- 根据任务需要调用浏览器、文件或本地 Agent 工具。

## 工作流
1. 识别任务对象和目标。
2. 整理输入材料和约束。
3. 按步骤执行任务。
4. 自检结果是否满足用户目标。

## 输出格式
使用结构化中文输出，包含结论、依据、结果和下一步建议。
"""
    return {"ok": True, "name": name, "content": content, "validation": validate_skill_content(content)}


def safe_project_relpath(value):
    raw = str(value or "").replace("\\", "/").strip()
    if not raw:
        raise ValueError("文件 path 不能为空")
    path = Path(raw)
    if path.is_absolute() or ".." in path.parts:
        raise ValueError(f"不允许的项目文件路径: {raw}")
    if raw.startswith("/") or raw.startswith("~"):
        raise ValueError(f"项目文件路径必须是相对路径: {raw}")
    first = path.parts[0] if path.parts else ""
    if first in {"node_modules", ".git", "dist", "build"}:
        raise ValueError(f"不允许写入生成目录或依赖目录: {raw}")
    return path


def normalize_project_files(args):
    files = args.get("files") or []
    if not isinstance(files, list) or not files:
        raise ValueError("files 必须是非空数组")
    if len(files) > 120:
        raise ValueError("单次最多写入 120 个文件")
    normalized = []
    seen = set()
    for item in files:
        if not isinstance(item, dict):
            raise ValueError("files 中每一项必须是对象")
        rel = safe_project_relpath(item.get("path"))
        key = str(rel)
        if key in seen:
            raise ValueError(f"重复文件路径: {key}")
        seen.add(key)
        content = item.get("content")
        if content is None:
            content = ""
        content = str(content)
        if len(content.encode("utf-8")) > 2 * 1024 * 1024:
            raise ValueError(f"单个文件过大: {key}")
        normalized.append((rel, content))
    if not any(str(path) == "package.json" for path, _ in normalized):
        raise ValueError("工程项目必须包含 package.json")
    return normalized


def run_project_command(command, cwd, timeout=180):
    executable = str(command[0])
    if executable not in allowed_commands():
        return {"ok": False, "skipped": True, "error": f"命令未在白名单内: {executable}"}
    proc = subprocess.run(
        [str(x) for x in command],
        cwd=str(cwd),
        text=True,
        capture_output=True,
        timeout=timeout,
        check=False,
    )
    return {
        "ok": proc.returncode == 0,
        "returncode": proc.returncode,
        "stdout": (proc.stdout or "")[:MAX_OUTPUT_CHARS],
        "stderr": (proc.stderr or "")[:MAX_OUTPUT_CHARS],
    }


def tool_project_generate(args):
    project_name = str(args.get("projectName") or args.get("name") or "ai-frontend-project")
    slug = safe_project_slug(project_name)
    target = args.get("targetDir") or args.get("outputDir")
    if target:
        target_dir = Path(str(target)).expanduser()
        if not target_dir.is_absolute():
            raise ValueError("targetDir 必须是绝对路径，或省略让系统写到桌面")
    else:
        target_dir = Path.home() / "Desktop" / "AI前端项目" / slug

    files = normalize_project_files(args)
    checkpoint_result = tool_checkpoint_create({
        "paths": [str(target_dir)],
        "label": f"project.generate 前置回滚点: {project_name}",
        "tool": "project.generate",
        "args": {"projectName": project_name, "targetDir": str(target_dir)},
    })
    target_dir.mkdir(parents=True, exist_ok=True)

    written = []
    for rel, content in files:
        path = target_dir / rel
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding="utf-8")
        written.append({"path": str(path), "relativePath": str(rel), "bytes": len(content.encode("utf-8"))})

    gitignore_path = target_dir / ".gitignore"
    if not gitignore_path.exists():
        gitignore_path.write_text("node_modules\ndist\n.env\n.DS_Store\n", encoding="utf-8")
        written.append({"path": str(gitignore_path), "relativePath": ".gitignore", "bytes": gitignore_path.stat().st_size})

    package_manager = str(args.get("packageManager") or "npm").lower()
    if package_manager not in {"npm", "pnpm"}:
        package_manager = "npm"

    commands = [
        f"cd {target_dir}",
        f"{package_manager} install",
        f"{package_manager} run dev",
        f"{package_manager} run build",
        "git init",
        "git add .",
    ]

    install_result = None
    build_result = None
    git_result = None
    git_add_result = None
    if args.get("runInstall"):
        install_cmd = [package_manager, "install"]
        install_result = run_project_command(install_cmd, target_dir, timeout=300)
    if args.get("runBuild"):
        if not args.get("runInstall"):
            build_result = {"ok": False, "skipped": True, "error": "未执行依赖安装，跳过构建"}
        else:
            build_result = run_project_command([package_manager, "run", "build"], target_dir, timeout=180)
    if args.get("runGitInit"):
        git_result = run_project_command(["git", "init"], target_dir, timeout=30)
        if git_result.get("ok"):
            git_add_result = run_project_command(["git", "add", "."], target_dir, timeout=30)

    return {
        "ok": True,
        "projectName": project_name,
        "framework": args.get("framework") or "react",
        "path": str(target_dir),
        "files": written,
        "commands": commands,
        "checkpoint": checkpoint_result,
        "install": install_result,
        "build": build_result,
        "git": git_result,
        "gitAdd": git_add_result,
    }


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 "詹老师的AIPROS")
    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}


def checkpoint_id():
    stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime())
    return f"{stamp}-{uuid.uuid4().hex[:8]}"


def path_stats_for_checkpoint(path, max_files=400, max_bytes=25 * 1024 * 1024):
    if not path.exists():
        return {"exists": False, "files": 0, "bytes": 0, "tooLarge": False}
    if path.is_file():
        size = path.stat().st_size
        return {"exists": True, "files": 1, "bytes": size, "tooLarge": size > max_bytes}
    files = 0
    total = 0
    for item in path.rglob("*"):
        if any(part in SKIP_DIRS for part in item.parts):
            continue
        if item.is_file():
            files += 1
            total += item.stat().st_size
            if files > max_files or total > max_bytes:
                return {"exists": True, "files": files, "bytes": total, "tooLarge": True}
    return {"exists": True, "files": files, "bytes": total, "tooLarge": False}


def copy_snapshot(src, dst):
    if src.is_dir():
        shutil.copytree(src, dst, symlinks=True, ignore=shutil.ignore_patterns(*SKIP_DIRS))
    else:
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, dst)


def remove_path(path):
    if not path.exists() and not path.is_symlink():
        return
    if path.is_dir() and not path.is_symlink():
        shutil.rmtree(path)
    else:
        path.unlink()


def checkpoint_manifest_path(checkpoint):
    cid = safe_filename(checkpoint)
    path = CHECKPOINT_ROOT / cid / "manifest.json"
    if not path.exists():
        raise FileNotFoundError(f"回滚点不存在: {checkpoint}")
    return path


def tool_checkpoint_create(args):
    raw_paths = args.get("paths") or ([args.get("path")] if args.get("path") else [])
    if not isinstance(raw_paths, list) or not raw_paths:
        raise ValueError("paths 必须是非空数组")
    max_files = min(int(args.get("maxFiles") or 400), 2000)
    max_bytes = min(int(args.get("maxBytes") or 25 * 1024 * 1024), 200 * 1024 * 1024)
    cid = checkpoint_id()
    root = CHECKPOINT_ROOT / cid
    items_dir = root / "items"
    items_dir.mkdir(parents=True, exist_ok=True)
    records = []
    warnings = []

    for idx, raw in enumerate(raw_paths):
        path = require_abs_path(raw)
        stats = path_stats_for_checkpoint(path, max_files=max_files, max_bytes=max_bytes)
        record = {
            "index": idx,
            "originalPath": str(path),
            "exists": stats["exists"],
            "type": "dir" if path.exists() and path.is_dir() else "file",
            "files": stats["files"],
            "bytes": stats["bytes"],
            "snapshot": None,
            "skipped": False,
        }
        if stats["tooLarge"]:
            record["skipped"] = True
            warnings.append(f"路径过大，已跳过快照: {path}")
        elif path.exists():
            snapshot = items_dir / str(idx)
            copy_snapshot(path, snapshot)
            record["snapshot"] = str(snapshot)
        records.append(record)

    manifest = {
        "id": cid,
        "label": str(args.get("label") or "Agent 自动回滚点"),
        "tool": args.get("tool") or "",
        "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime()),
        "paths": [str(require_abs_path(p)) for p in raw_paths],
        "items": records,
        "warnings": warnings,
        "args": args.get("args") or {},
    }
    (root / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
    return {
        "ok": True,
        "id": cid,
        "label": manifest["label"],
        "path": str(root),
        "createdAt": manifest["createdAt"],
        "items": records,
        "warnings": warnings,
    }


def tool_checkpoint_list(args):
    limit = min(int(args.get("limit") or 20), 100)
    if not CHECKPOINT_ROOT.exists():
        return {"ok": True, "checkpoints": []}
    items = []
    for manifest_path in sorted(CHECKPOINT_ROOT.glob("*/manifest.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:limit]:
        try:
            data = json.loads(manifest_path.read_text(encoding="utf-8"))
        except Exception:
            continue
        items.append({
            "id": data.get("id") or manifest_path.parent.name,
            "label": data.get("label") or "",
            "tool": data.get("tool") or "",
            "createdAt": data.get("createdAt") or "",
            "paths": data.get("paths") or [],
            "warnings": data.get("warnings") or [],
        })
    return {"ok": True, "checkpoints": items}


def tool_checkpoint_read(args):
    cid = args.get("id") or args.get("checkpointId")
    if not cid:
        raise ValueError("缺少 id")
    data = json.loads(checkpoint_manifest_path(cid).read_text(encoding="utf-8"))
    return {"ok": True, "checkpoint": data}


def tool_checkpoint_restore(args):
    cid = args.get("id") or args.get("checkpointId")
    if not cid:
        raise ValueError("缺少 id")
    if not args.get("confirm"):
        return {"ok": False, "dryRun": True, "error": "恢复回滚点需要 confirm:true"}
    manifest_path = checkpoint_manifest_path(cid)
    data = json.loads(manifest_path.read_text(encoding="utf-8"))
    restored = []
    for record in reversed(data.get("items") or []):
        original = require_abs_path(record.get("originalPath"))
        if record.get("skipped"):
            restored.append({"path": str(original), "restored": False, "skipped": True})
            continue
        if record.get("exists"):
            snapshot = Path(str(record.get("snapshot") or ""))
            if not snapshot.exists():
                restored.append({"path": str(original), "restored": False, "error": "snapshot missing"})
                continue
            remove_path(original)
            original.parent.mkdir(parents=True, exist_ok=True)
            copy_snapshot(snapshot, original)
            restored.append({"path": str(original), "restored": True, "existedBefore": True})
        else:
            remove_path(original)
            restored.append({"path": str(original), "restored": True, "existedBefore": False})
    return {"ok": True, "id": data.get("id") or cid, "restored": restored}


TOOLS = {
    "fs.read": tool_fs_read,
    "fs.write": tool_fs_write,
    "fs.list": tool_fs_list,
    "fs.stat": tool_fs_stat,
    "fs.search": tool_fs_search,
    "fs.copy": tool_fs_copy,
    "fs.move": tool_fs_move,
    "fs.delete": tool_fs_delete,
    "shell.exec": tool_shell_exec,
    "clipboard.read": tool_clipboard_read,
    "clipboard.write": tool_clipboard_write,
    "notify.send": tool_notify,
    "cloudflare.pages.deploy": tool_cloudflare_pages_deploy,
    "checkpoint.create": tool_checkpoint_create,
    "checkpoint.list": tool_checkpoint_list,
    "checkpoint.read": tool_checkpoint_read,
    "checkpoint.restore": tool_checkpoint_restore,
    "project.generate": tool_project_generate,
    "skill.list": tool_skill_list,
    "skill.read": tool_skill_read,
    "skill.write": tool_skill_write,
    "skill.validate": tool_skill_validate,
    "skill.evaluate": tool_skill_evaluate,
    "skill.benchmark": tool_skill_benchmark,
    "skill.scaffold": tool_skill_scaffold,
}


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()
