#!/usr/bin/env python3
import sys
from datetime import datetime, timezone
from pathlib import Path
from shutil import copy2, copytree, rmtree, which
import subprocess

try:
    import tomllib
except ModuleNotFoundError:  # Python < 3.11
    tomllib = None

ORDER = [
    "vendor",
    "sync_rules",
    "sync_memory_bank",
    "sync_prompts",
    "sync_standards",
    "install_skills",
    "format_md",
]
SCRIPT_DIR = Path(__file__).resolve().parent
PLAYBOOK_ROOT = SCRIPT_DIR.parent


def usage() -> str:
    return "Usage:\n  python scripts/playbook.py -config <path>\n  python scripts/playbook.py -h"


def strip_inline_comment(value: str) -> str:
    in_single = False
    in_double = False
    escape = False
    for idx, ch in enumerate(value):
        if escape:
            escape = False
            continue
        if in_double and ch == "\\":
            escape = True
            continue
        if ch == "'" and not in_double:
            in_single = not in_single
            continue
        if ch == '"' and not in_single:
            in_double = not in_double
            continue
        if ch == "#" and not in_single and not in_double:
            return value[:idx].rstrip()
    return value


def split_list_items(raw: str) -> list[str]:
    items: list[str] = []
    buf: list[str] = []
    in_single = False
    in_double = False
    escape = False
    for ch in raw:
        if escape:
            buf.append(ch)
            escape = False
            continue
        if in_double and ch == "\\":
            buf.append(ch)
            escape = True
            continue
        if ch == "'" and not in_double:
            in_single = not in_single
            buf.append(ch)
            continue
        if ch == '"' and not in_single:
            in_double = not in_double
            buf.append(ch)
            continue
        if ch == "," and not in_single and not in_double:
            items.append("".join(buf).strip())
            buf = []
            continue
        buf.append(ch)
    tail = "".join(buf).strip()
    if tail:
        items.append(tail)
    return items


def parse_toml_value(raw: str) -> object:
    value = raw.strip()
    if not value:
        return ""
    if value.startswith("[") and value.endswith("]"):
        inner = value[1:-1].strip()
        if not inner:
            return []
        return [parse_toml_value(item) for item in split_list_items(inner)]
    lowered = value.lower()
    if lowered == "true":
        return True
    if lowered == "false":
        return False
    if value[0] in ("'", '"') and value[-1] == value[0]:
        if value[0] == "'":
            return value[1:-1]
        import ast

        try:
            return ast.literal_eval(value)
        except (ValueError, SyntaxError):
            return value[1:-1]
    try:
        if "." in value:
            return float(value)
        return int(value)
    except ValueError:
        return value


def loads_toml_minimal(raw: str) -> dict:
    data: dict[str, dict] = {}
    current = None
    for line in raw.splitlines():
        stripped = line.strip()
        if not stripped or stripped.startswith("#"):
            continue
        if stripped.startswith("[") and stripped.endswith("]"):
            section = stripped[1:-1].strip()
            if not section:
                raise ValueError("empty section header")
            current = data.setdefault(section, {})
            if not isinstance(current, dict):
                raise ValueError(f"invalid section: {section}")
            continue
        if "=" not in stripped:
            raise ValueError(f"invalid line: {line}")
        key, value = stripped.split("=", 1)
        key = key.strip()
        if not key:
            raise ValueError("missing key")
        value = strip_inline_comment(value.strip())
        target = current if current is not None else data
        target[key] = parse_toml_value(value)
    return data


def load_config(path: Path) -> dict:
    raw = path.read_text(encoding="utf-8")
    if tomllib is not None:
        return tomllib.loads(raw)
    return loads_toml_minimal(raw)


def log(message: str) -> None:
    print(message)


def ensure_dir(path: Path) -> None:
    path.mkdir(parents=True, exist_ok=True)


def normalize_langs(raw: object) -> list[str]:
    if raw is None:
        return ["tsl"]
    if isinstance(raw, str):
        langs = [raw]
    else:
        langs = list(raw)
    cleaned: list[str] = []
    for lang in langs:
        item = str(lang).strip()
        if not item:
            continue
        if "/" in item or "\\" in item or ".." in item:
            raise ValueError(f"invalid lang: {item}")
        cleaned.append(item)
    if not cleaned:
        raise ValueError("langs is empty")
    return cleaned


def resolve_main_language(config: dict, context: dict) -> str:
    raw = config.get("main_language")
    if raw is not None and str(raw).strip():
        return str(raw).strip()

    full_config = context.get("config", {})
    if isinstance(full_config, dict):
        sync_conf = full_config.get("sync_standards")
        if isinstance(sync_conf, dict):
            langs_raw = sync_conf.get("langs")
            if langs_raw is not None:
                try:
                    langs = normalize_langs(langs_raw)
                except ValueError:
                    langs = []
                if langs:
                    return langs[0]

    return "tsl"


def resolve_playbook_scripts(project_root: Path, context: dict) -> str:
    playbook_scripts = PLAYBOOK_ROOT / "scripts"
    try:
        rel = playbook_scripts.resolve().relative_to(project_root.resolve())
        return rel.as_posix()
    except ValueError:
        full_config = context.get("config", {})
        if isinstance(full_config, dict):
            vendor_conf = full_config.get("vendor")
            if isinstance(vendor_conf, dict):
                target_dir = vendor_conf.get("target_dir")
                if target_dir:
                    target_str = str(target_dir).strip().rstrip("/").rstrip("\\")
                    if target_str:
                        return f"{target_str}/scripts"
    return "docs/standards/playbook/scripts"


def read_git_commit(root: Path) -> str:
    try:
        result = subprocess.run(
            ["git", "-C", str(root), "rev-parse", "HEAD"],
            capture_output=True,
            text=True,
            check=True,
        )
    except (OSError, subprocess.CalledProcessError):
        return "N/A"
    return result.stdout.strip() or "N/A"


def write_docs_index(dest_prefix: Path, langs: list[str]) -> None:
    lines = [
        "# 文档导航（Docs Index）",
        "",
        f"本快照为裁剪版 Playbook（langs: {','.join(langs)}）。",
        "",
        "## 跨语言（common）",
        "",
        "- 提交信息与版本号：`common/commit_message.md`",
    ]
    for lang in langs:
        if lang == "tsl":
            lines += [
                "",
                "## TSL（tsl）",
                "",
                "- 代码风格：`tsl/code_style.md`",
                "- 命名规范：`tsl/naming.md`",
                "- 语法手册：`tsl/syntax_book/index.md`",
                "- 工具链与验证命令（模板）：`tsl/toolchain.md`",
            ]
        elif lang == "cpp":
            lines += [
                "",
                "## C++（cpp）",
                "",
                "- 代码风格：`cpp/code_style.md`",
                "- 命名规范：`cpp/naming.md`",
                "- 工具链与验证命令（模板）：`cpp/toolchain.md`",
                "- 第三方依赖（Conan）：`cpp/dependencies_conan.md`",
                "- clangd 配置：`cpp/clangd.md`",
            ]
        elif lang == "python":
            lines += [
                "",
                "## Python（python）",
                "",
                "- 代码风格：`python/style_guide.md`",
                "- 工具链：`python/tooling.md`",
                "- 配置清单：`python/configuration.md`",
            ]
        elif lang == "markdown":
            lines += [
                "",
                "## Markdown（markdown）",
                "",
                "- 代码块与行内代码格式：`markdown/index.md`",
            ]
    docs_index = dest_prefix / "docs/index.md"
    ensure_dir(docs_index.parent)
    docs_index.write_text("\n".join(lines) + "\n", encoding="utf-8")


def write_snapshot_readme(dest_prefix: Path, langs: list[str]) -> None:
    lines = [
        "# Playbook（裁剪快照）",
        "",
        f"本目录为从 Playbook vendoring 的裁剪快照（langs: {','.join(langs)}）。",
        "",
        "## 使用",
        "",
        "在目标项目根目录执行：",
        "",
        "```sh",
        "python docs/standards/playbook/scripts/playbook.py -config playbook.toml",
        "```",
        "",
        "配置示例：`docs/standards/playbook/playbook.toml.example`",
        "",
        "文档入口：",
        "",
        "- `docs/standards/playbook/docs/index.md`",
        "- `.agents/index.md`",
    ]
    (dest_prefix / "README.md").write_text("\n".join(lines) + "\n", encoding="utf-8")


def write_source_file(dest_prefix: Path, langs: list[str]) -> None:
    commit = read_git_commit(PLAYBOOK_ROOT)
    timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    lines = [
        "# SOURCE",
        "",
        f"- Source: {PLAYBOOK_ROOT}",
        f"- Commit: {commit}",
        f"- Date: {timestamp}",
        f"- Langs: {','.join(langs)}",
        "- Generated-by: scripts/playbook.py",
    ]
    (dest_prefix / "SOURCE.md").write_text("\n".join(lines) + "\n", encoding="utf-8")


def vendor_action(config: dict, context: dict) -> int:
    try:
        langs = normalize_langs(config.get("langs"))
    except ValueError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 2

    target_dir = config.get("target_dir", "docs/standards/playbook")
    target_path = Path(target_dir)
    if target_path.is_absolute() or ".." in target_path.parts:
        print(f"ERROR: invalid target_dir: {target_dir}", file=sys.stderr)
        return 2

    project_root: Path = context["project_root"]
    dest_prefix = project_root / target_path
    dest_standards = dest_prefix.parent

    ensure_dir(dest_standards)

    if dest_prefix.exists():
        timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
        backup = dest_standards / f"{dest_prefix.name}.bak.{timestamp}"
        dest_prefix.rename(backup)
        log(f"Backed up existing snapshot -> {backup}")

    ensure_dir(dest_prefix)

    gitattributes_src = PLAYBOOK_ROOT / ".gitattributes"
    if gitattributes_src.is_file():
        copy2(gitattributes_src, dest_prefix / ".gitattributes")

    copytree(PLAYBOOK_ROOT / "scripts", dest_prefix / "scripts")
    copytree(PLAYBOOK_ROOT / "codex", dest_prefix / "codex")
    copy2(PLAYBOOK_ROOT / "SKILLS.md", dest_prefix / "SKILLS.md")

    common_docs = PLAYBOOK_ROOT / "docs/common"
    if common_docs.is_dir():
        copytree(common_docs, dest_prefix / "docs/common")

    rulesets_root = PLAYBOOK_ROOT / "rulesets"
    ensure_dir(dest_prefix / "rulesets")
    if (rulesets_root / "index.md").is_file():
        copy2(rulesets_root / "index.md", dest_prefix / "rulesets/index.md")

    templates_root = PLAYBOOK_ROOT / "templates"
    templates_dst = dest_prefix / "templates"
    ensure_dir(templates_dst)

    templates_ci = templates_root / "ci"
    if templates_ci.is_dir():
        copytree(templates_ci, templates_dst / "ci")

    for name in ("AGENTS.template.md", "AGENT_RULES.template.md", "README.md"):
        src = templates_root / name
        if src.is_file():
            copy2(src, templates_dst / name)

    memory_src = templates_root / "memory-bank"
    if memory_src.is_dir():
        copytree(memory_src, templates_dst / "memory-bank")

    prompts_src = templates_root / "prompts"
    if prompts_src.is_dir():
        copytree(prompts_src, templates_dst / "prompts")

    for lang in langs:
        docs_src = PLAYBOOK_ROOT / "docs" / lang
        rules_src = PLAYBOOK_ROOT / "rulesets" / lang
        if not docs_src.is_dir():
            print(f"ERROR: docs not found for lang={lang}", file=sys.stderr)
            return 2
        if not rules_src.is_dir():
            print(f"ERROR: rulesets not found for lang={lang}", file=sys.stderr)
            return 2
        copytree(docs_src, dest_prefix / "docs" / lang)
        copytree(rules_src, dest_prefix / "rulesets" / lang)
        templates_src = PLAYBOOK_ROOT / "templates" / lang
        if templates_src.is_dir():
            copytree(templates_src, dest_prefix / "templates" / lang)

    example_config = PLAYBOOK_ROOT / "playbook.toml.example"
    if example_config.is_file():
        copy2(example_config, dest_prefix / "playbook.toml.example")

    write_docs_index(dest_prefix, langs)
    write_snapshot_readme(dest_prefix, langs)
    write_source_file(dest_prefix, langs)

    log(f"Vendored snapshot -> {dest_prefix}")
    return 0


def replace_placeholders(
    text: str,
    project_name: str | None,
    date_value: str,
    main_language: str | None,
    playbook_scripts: str | None,
) -> str:
    result = text.replace("{{DATE}}", date_value)
    if project_name:
        result = result.replace("{{PROJECT_NAME}}", project_name)
    if main_language:
        result = result.replace("{{MAIN_LANGUAGE}}", main_language)
    if playbook_scripts:
        result = result.replace("{{PLAYBOOK_SCRIPTS}}", playbook_scripts)
    return result


def backup_path(path: Path, no_backup: bool) -> None:
    if not path.exists() or no_backup:
        return
    timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
    backup = path.with_name(f"{path.name}.bak.{timestamp}")
    path.rename(backup)
    log(f"Backed up: {path} -> {backup}")


def rename_template_files(root: Path) -> None:
    for template in root.rglob("*.template.md"):
        target = template.with_name(template.name.replace(".template.md", ".md"))
        template.rename(target)


def replace_placeholders_in_dir(
    root: Path,
    project_name: str | None,
    date_value: str,
    main_language: str | None,
    playbook_scripts: str | None,
) -> None:
    for file_path in root.rglob("*.md"):
        text = file_path.read_text(encoding="utf-8")
        updated = replace_placeholders(
            text, project_name, date_value, main_language, playbook_scripts
        )
        if updated != text:
            file_path.write_text(updated, encoding="utf-8")


def replace_placeholders_in_file(
    file_path: Path,
    project_name: str | None,
    date_value: str,
    main_language: str | None,
    playbook_scripts: str | None,
) -> None:
    if file_path.suffix != ".md":
        return
    text = file_path.read_text(encoding="utf-8")
    updated = replace_placeholders(
        text, project_name, date_value, main_language, playbook_scripts
    )
    if updated != text:
        file_path.write_text(updated, encoding="utf-8")


def resolve_template_target(
    template_file: Path, template_root: Path, target_root: Path
) -> Path:
    rel = template_file.relative_to(template_root)
    if rel.name.endswith(".template.md"):
        rel = rel.with_name(rel.name.replace(".template.md", ".md"))
    return target_root / rel


def sync_directory(
    template_dir: Path,
    target_dir: Path,
    project_name: str | None,
    date_value: str,
    main_language: str | None,
    playbook_scripts: str | None,
    force: bool,
    no_backup: bool,
) -> None:
    for template_file in template_dir.rglob("*"):
        if not template_file.is_file():
            continue
        target_file = resolve_template_target(
            template_file, template_dir, target_dir
        )
        ensure_dir(target_file.parent)
        if target_file.exists():
            if not force:
                continue
            backup_path(target_file, no_backup)
        copy2(template_file, target_file)
        replace_placeholders_in_file(
            target_file,
            project_name,
            date_value,
            main_language,
            playbook_scripts,
        )


def extract_block_lines(text: str, start: str, end: str) -> list[str]:
    lines = text.splitlines()
    block: list[str] = []
    in_block = False
    for line in lines:
        if line.strip() == start:
            in_block = True
        if in_block:
            block.append(line)
        if in_block and line.strip() == end:
            break
    if not block or block[-1].strip() != end:
        return []
    return block


def update_agents_section(
    agents_path: Path,
    template_path: Path,
    start_marker: str,
    end_marker: str,
    project_name: str | None,
    date_value: str,
    main_language: str | None,
    playbook_scripts: str | None,
) -> None:
    template_text = template_path.read_text(encoding="utf-8")
    template_text = replace_placeholders(
        template_text, project_name, date_value, main_language, playbook_scripts
    )
    block = extract_block_lines(template_text, start_marker, end_marker)
    if not block:
        log("Skip: markers not found in template")
        return

    if not agents_path.exists():
        agents_path.write_text(template_text + "\n", encoding="utf-8")
        log("Created: AGENTS.md")
        return

    agents_text = agents_path.read_text(encoding="utf-8")
    if start_marker in agents_text:
        lines = agents_text.splitlines()
        updated: list[str] = []
        in_block = False
        replaced = False
        for line in lines:
            if not replaced and line.strip() == start_marker:
                updated.extend(block)
                in_block = True
                replaced = True
                continue
            if in_block:
                if line.strip() == end_marker:
                    in_block = False
                continue
            updated.append(line)
        agents_path.write_text("\n".join(updated) + "\n", encoding="utf-8")
        log("Updated: AGENTS.md (section)")
    else:
        if ".agents/index.md" in agents_text:
            log("Skip: AGENTS.md already references .agents/index.md")
            return
        updated = agents_text.rstrip("\n") + "\n\n" + "\n".join(block) + "\n"
        agents_path.write_text(updated, encoding="utf-8")
        log("Appended: AGENTS.md (section)")


def resolve_project_name(context: dict) -> str | None:
    config = context.get("config", {})
    if not isinstance(config, dict):
        return None
    memory_conf = config.get("sync_memory_bank")
    if isinstance(memory_conf, dict):
        raw = memory_conf.get("project_name")
        if raw is not None and str(raw).strip():
            return str(raw).strip()
    return None


def resolve_template_date(context: dict) -> str:
    config = context.get("config", {})
    if isinstance(config, dict):
        for key in ("sync_rules", "sync_memory_bank", "sync_prompts"):
            section = config.get(key)
            if isinstance(section, dict):
                value = section.get("date")
                if value:
                    return str(value)
    return datetime.now().strftime("%Y-%m-%d")


def sync_agents_template(context: dict) -> int:
    project_root: Path = context["project_root"]
    if project_root.resolve() == PLAYBOOK_ROOT.resolve():
        log("Skip: playbook root equals project root.")
        return 0

    templates_dir = PLAYBOOK_ROOT / "templates"
    agents_src = templates_dir / "AGENTS.template.md"
    if not agents_src.is_file():
        return 0

    project_name = resolve_project_name(context)
    main_language = resolve_main_language({}, context)
    playbook_scripts = resolve_playbook_scripts(project_root, context)
    date_value = resolve_template_date(context)

    agents_dst = project_root / "AGENTS.md"
    if agents_dst.exists():
        agents_text = agents_dst.read_text(encoding="utf-8")
        if "<!-- playbook:framework:start -->" in agents_text:
            start_marker = "<!-- playbook:framework:start -->"
            end_marker = "<!-- playbook:framework:end -->"
        elif "<!-- playbook:templates:start -->" in agents_text:
            start_marker = "<!-- playbook:templates:start -->"
            end_marker = "<!-- playbook:templates:end -->"
        else:
            start_marker = "<!-- playbook:templates:start -->"
            end_marker = "<!-- playbook:templates:end -->"
    else:
        start_marker = "<!-- playbook:framework:start -->"
        end_marker = "<!-- playbook:framework:end -->"

    update_agents_section(
        agents_dst,
        agents_src,
        start_marker,
        end_marker,
        project_name,
        date_value,
        main_language,
        playbook_scripts,
    )
    return 0


def should_sync_agents(config: dict) -> bool:
    for key in ("sync_rules", "sync_memory_bank", "sync_prompts", "sync_standards"):
        if key in config:
            return True
    return False


def sync_rules_action(config: dict, context: dict) -> int:
    project_root: Path = context["project_root"]
    if project_root.resolve() == PLAYBOOK_ROOT.resolve():
        log("Skip: playbook root equals project root.")
        return 0

    templates_dir = PLAYBOOK_ROOT / "templates"
    rules_src = templates_dir / "AGENT_RULES.template.md"
    if not rules_src.is_file():
        print(f"ERROR: template not found: {rules_src}", file=sys.stderr)
        return 2

    rules_dst = project_root / "AGENT_RULES.md"
    force = bool(config.get("force", False))
    if rules_dst.exists() and not force:
        log("AGENT_RULES.md already exists. Use force to overwrite.")
        return 0

    project_name = resolve_project_name(context)
    main_language = resolve_main_language(config, context)
    playbook_scripts = resolve_playbook_scripts(project_root, context)
    date_value = config.get("date") or datetime.now().strftime("%Y-%m-%d")
    no_backup = bool(config.get("no_backup", False))

    backup_path(rules_dst, no_backup)
    text = rules_src.read_text(encoding="utf-8")
    text = replace_placeholders(
        text, project_name, date_value, main_language, playbook_scripts
    )
    rules_dst.write_text(text + "\n", encoding="utf-8")
    log("Synced: AGENT_RULES.md")
    return 0


def sync_memory_bank_action(config: dict, context: dict) -> int:
    project_root: Path = context["project_root"]
    if project_root.resolve() == PLAYBOOK_ROOT.resolve():
        log("Skip: playbook root equals project root.")
        return 0

    templates_dir = PLAYBOOK_ROOT / "templates"
    memory_src = templates_dir / "memory-bank"
    if not memory_src.is_dir():
        print(f"ERROR: templates not found: {memory_src}", file=sys.stderr)
        return 2

    project_name = config.get("project_name")
    main_language = resolve_main_language(config, context)
    playbook_scripts = resolve_playbook_scripts(project_root, context)
    date_value = config.get("date") or datetime.now().strftime("%Y-%m-%d")
    force = bool(config.get("force", False))
    no_backup = bool(config.get("no_backup", False))

    memory_dst = project_root / "memory-bank"
    ensure_dir(memory_dst)
    sync_directory(
        memory_src,
        memory_dst,
        project_name,
        date_value,
        main_language,
        playbook_scripts,
        force,
        no_backup,
    )
    log("Synced: memory-bank/")
    return 0


def sync_prompts_action(config: dict, context: dict) -> int:
    project_root: Path = context["project_root"]
    if project_root.resolve() == PLAYBOOK_ROOT.resolve():
        log("Skip: playbook root equals project root.")
        return 0

    templates_dir = PLAYBOOK_ROOT / "templates"
    prompts_src = templates_dir / "prompts"
    if not prompts_src.is_dir():
        print(f"ERROR: templates not found: {prompts_src}", file=sys.stderr)
        return 2

    project_name = resolve_project_name(context)
    main_language = resolve_main_language(config, context)
    playbook_scripts = resolve_playbook_scripts(project_root, context)
    date_value = config.get("date") or datetime.now().strftime("%Y-%m-%d")
    force = bool(config.get("force", False))
    no_backup = bool(config.get("no_backup", False))

    prompts_dst = project_root / "docs/prompts"
    ensure_dir(prompts_dst.parent)
    ensure_dir(prompts_dst)
    sync_directory(
        prompts_src,
        prompts_dst,
        project_name,
        date_value,
        main_language,
        playbook_scripts,
        force,
        no_backup,
    )
    log("Synced: docs/prompts/")
    return 0


def render_agents_block(langs: list[str]) -> list[str]:
    entries = [f"`.agents/{lang}/index.md`" for lang in langs]
    langs_line = "、".join(entries) if entries else ""
    lines = [
        "<!-- playbook:agents:start -->",
        "",
        "请以 `.agents/` 下的规则为准：",
        "",
        "- 入口：`.agents/index.md`",
        f"- 语言规则：{langs_line}" if langs_line else "- 语言规则：",
        "<!-- playbook:agents:end -->",
    ]
    return lines


def update_agents_block(agents_md: Path, block_lines: list[str]) -> None:
    start = "<!-- playbook:agents:start -->"
    end = "<!-- playbook:agents:end -->"
    if not agents_md.exists():
        content = "# Agent Instructions\n\n" + "\n".join(block_lines) + "\n"
        agents_md.write_text(content, encoding="utf-8")
        log("Created AGENTS.md")
        return

    text = agents_md.read_text(encoding="utf-8")
    if start in text:
        lines = text.splitlines()
        updated: list[str] = []
        in_block = False
        replaced = False
        for line in lines:
            if not replaced and line.strip() == start:
                updated.extend(block_lines)
                in_block = True
                replaced = True
                continue
            if in_block:
                if line.strip() == end:
                    in_block = False
                continue
            updated.append(line)
        agents_md.write_text("\n".join(updated) + "\n", encoding="utf-8")
        log("Updated AGENTS.md (playbook block).")
    else:
        if ".agents/index.md" in text:
            log("Skip: AGENTS.md already references .agents/index.md")
            return
        updated = text.rstrip("\n") + "\n\n" + "\n".join(block_lines) + "\n"
        agents_md.write_text(updated, encoding="utf-8")
        log("Appended playbook block to AGENTS.md")


def create_agents_index(agents_root: Path, langs: list[str], docs_prefix: str | None) -> None:
    agents_index = agents_root / "index.md"
    if agents_index.exists():
        return
    lines = [
        "# .agents（多语言）",
        "",
        "本目录用于存放仓库级/语言级的代理规则集。",
        "",
        "建议约定：",
        "",
        "- `.agents/tsl/`：TSL 相关规则集（由 playbook 同步；适用于 `.tsl`/`.tsf`）",
        "- `.agents/cpp/`：C++ 相关规则集（由 playbook 同步；适用于 C++23/Modules）",
        "- `.agents/python/`：Python 相关规则集（由 playbook 同步）",
        "- `.agents/markdown/`：Markdown 相关规则集（仅代码格式化）",
        "",
        "规则发生冲突时，建议以“更靠近代码的目录规则更具体”为准。",
        "",
        "入口建议从：",
        "",
    ]
    for lang in langs:
        lines.append(f"- `.agents/{lang}/index.md`")
    lines += [
        "",
        "标准快照文档入口：",
        "",
        f"- {docs_prefix or 'docs/standards/playbook/docs/'}",
    ]
    agents_index.write_text("\n".join(lines) + "\n", encoding="utf-8")
    log("Created .agents/index.md")


def rewrite_agents_docs_links(agents_dir: Path, docs_prefix: str) -> None:
    replacements = {
        "`docs/tsl/": f"`{docs_prefix}/tsl/",
        "`docs/cpp/": f"`{docs_prefix}/cpp/",
        "`docs/python/": f"`{docs_prefix}/python/",
        "`docs/markdown/": f"`{docs_prefix}/markdown/",
        "`docs/common/": f"`{docs_prefix}/common/",
    }
    for md_path in agents_dir.glob("*.md"):
        if not md_path.is_file():
            continue
        text = md_path.read_text(encoding="utf-8")
        updated = text
        for old, new in replacements.items():
            updated = updated.replace(old, new)
        if updated != text:
            md_path.write_text(updated, encoding="utf-8")


def read_gitattributes_entries(path: Path) -> list[str]:
    entries: list[str] = []
    for line in path.read_text(encoding="utf-8").splitlines():
        stripped = line.strip()
        if not stripped or stripped.startswith("#"):
            continue
        entries.append(stripped)
    return entries


def sync_gitattributes_overwrite(src: Path, dst: Path, no_backup: bool) -> None:
    if src.resolve() == dst.resolve():
        log("Skip: .gitattributes source equals destination.")
        return
    backup_path(dst, no_backup)
    copy2(src, dst)
    log("Synced .gitattributes from standards (overwrite).")


def sync_gitattributes_append(
    src: Path, dst: Path, source_note: str, no_backup: bool
) -> None:
    src_entries = read_gitattributes_entries(src)
    dst_entries: list[str] = []
    if dst.exists():
        dst_entries = read_gitattributes_entries(dst)
    missing = [line for line in src_entries if line not in set(dst_entries)]
    if not missing:
        log("No missing .gitattributes rules to append.")
        return

    original = dst.read_text(encoding="utf-8") if dst.exists() else ""
    backup_path(dst, no_backup)
    header = f"# Added from playbook .gitattributes (source: {source_note})"
    content = original.rstrip("\n")
    if content:
        content += "\n\n"
    content += header + "\n" + "\n".join(missing) + "\n"
    dst.write_text(content, encoding="utf-8")
    log("Appended missing .gitattributes rules from standards.")


def sync_gitattributes_block(src: Path, dst: Path, no_backup: bool) -> None:
    begin = "# BEGIN playbook .gitattributes"
    end = "# END playbook .gitattributes"
    begin_old = "# BEGIN tsl-playbook .gitattributes"
    end_old = "# END tsl-playbook .gitattributes"

    src_lines = src.read_text(encoding="utf-8").splitlines()
    block_lines = [begin] + src_lines + [end]

    if dst.exists():
        original = dst.read_text(encoding="utf-8").splitlines()
        updated: list[str] = []
        in_block = False
        replaced = False
        for line in original:
            if line == begin or line == begin_old:
                if not replaced:
                    updated.extend(block_lines)
                    replaced = True
                in_block = True
                continue
            if in_block:
                if line == end or line == end_old:
                    in_block = False
                continue
            updated.append(line)
        if not replaced:
            if updated and updated[-1].strip():
                updated.append("")
            updated.extend(block_lines)
        backup_path(dst, no_backup)
        dst.write_text("\n".join(updated) + "\n", encoding="utf-8")
    else:
        dst.write_text("\n".join(block_lines) + "\n", encoding="utf-8")
    log("Synced .gitattributes from standards (block).")


def sync_standards_action(config: dict, context: dict) -> int:
    if "langs" not in config:
        print("ERROR: langs is required for sync_standards", file=sys.stderr)
        return 2
    try:
        langs = normalize_langs(config.get("langs"))
    except ValueError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 2

    project_root: Path = context["project_root"]
    agents_root = project_root / ".agents"
    ensure_dir(agents_root)

    no_backup = bool(config.get("no_backup", False))
    timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
    for lang in langs:
        src = PLAYBOOK_ROOT / "rulesets" / lang
        if not src.is_dir():
            print(f"ERROR: agents ruleset not found: {src}", file=sys.stderr)
            return 2
        dst = agents_root / lang
        if dst.exists():
            if no_backup:
                rmtree(dst)
            else:
                backup = agents_root / f"{lang}.bak.{timestamp}"
                dst.rename(backup)
                log(f"Backed up existing {lang} agents -> {backup.name}")
        copytree(src, dst)
        log(f"Synced .agents/{lang} from standards.")

    docs_prefix = None
    try:
        rel_snapshot = PLAYBOOK_ROOT.resolve().relative_to(project_root.resolve())
        if str(rel_snapshot) != ".":
            docs_prefix = f"{rel_snapshot.as_posix()}/docs"
    except ValueError:
        docs_prefix = None

    if docs_prefix:
        for lang in langs:
            rewrite_agents_docs_links(agents_root / lang, docs_prefix)

    agents_md = project_root / "AGENTS.md"
    block_lines = render_agents_block(langs)
    update_agents_block(agents_md, block_lines)

    create_agents_index(agents_root, langs, docs_prefix)

    gitattributes_src = PLAYBOOK_ROOT / ".gitattributes"
    if gitattributes_src.is_file():
        mode = str(config.get("gitattr_mode", "append")).lower()
        gitattributes_dst = project_root / ".gitattributes"
        source_note = str(gitattributes_src)
        try:
            source_note = str(gitattributes_src.resolve().relative_to(project_root.resolve()))
        except ValueError:
            source_note = str(gitattributes_src)

        if mode == "skip":
            log("Skip: .gitattributes sync (mode=skip).")
        elif mode == "overwrite":
            sync_gitattributes_overwrite(gitattributes_src, gitattributes_dst, no_backup)
        elif mode == "block":
            sync_gitattributes_block(gitattributes_src, gitattributes_dst, no_backup)
        else:
            sync_gitattributes_append(
                gitattributes_src, gitattributes_dst, source_note, no_backup
            )

    return 0


def normalize_names(raw: object, label: str) -> list[str]:
    if raw is None:
        raise ValueError(f"{label} is required")
    if isinstance(raw, str):
        items = [raw]
    else:
        items = list(raw)
    cleaned: list[str] = []
    for item in items:
        name = str(item).strip()
        if not name:
            continue
        if "/" in name or "\\" in name or ".." in name:
            raise ValueError(f"invalid {label}: {name}")
        cleaned.append(name)
    if not cleaned:
        raise ValueError(f"{label} is empty")
    return cleaned


def normalize_globs(raw: object) -> list[str]:
    if raw is None:
        return ["**/*.md"]
    if isinstance(raw, str):
        items = [raw]
    else:
        items = list(raw)
    cleaned = [str(item).strip() for item in items if str(item).strip()]
    return cleaned or ["**/*.md"]


def install_skills_action(config: dict, context: dict) -> int:
    mode = str(config.get("mode", "list")).lower()
    codex_home = Path(config.get("codex_home", "~/.codex")).expanduser()
    if not codex_home.is_absolute():
        codex_home = (context["project_root"] / codex_home).resolve()

    skills_src_root = PLAYBOOK_ROOT / "codex/skills"
    if not skills_src_root.is_dir():
        print(f"ERROR: skills source not found: {skills_src_root}", file=sys.stderr)
        return 2

    skills_dst_root = codex_home / "skills"
    ensure_dir(skills_dst_root)

    if mode == "all":
        skills = [
            path.name
            for path in skills_src_root.iterdir()
            if path.is_dir() and not path.name.startswith(".")
        ]
    elif mode == "list":
        try:
            skills = normalize_names(config.get("skills"), "skills")
        except ValueError as exc:
            print(f"ERROR: {exc}", file=sys.stderr)
            return 2
    else:
        print("ERROR: mode must be list or all", file=sys.stderr)
        return 2

    timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
    for name in skills:
        src = skills_src_root / name
        if not src.is_dir():
            print(f"ERROR: skill not found: {name}", file=sys.stderr)
            return 2
        dst = skills_dst_root / name
        if dst.exists():
            backup = skills_dst_root / f"{name}.bak.{timestamp}"
            dst.rename(backup)
            log(f"Backed up existing skill: {name} -> {backup.name}")
        copytree(src, dst)
        log(f"Installed: {name}")

    return 0


def format_md_action(config: dict, context: dict) -> int:
    tool = str(config.get("tool", "prettier")).lower()
    if tool != "prettier":
        print("ERROR: format_md.tool only supports prettier", file=sys.stderr)
        return 2

    project_root: Path = context["project_root"]
    prettier = project_root / "node_modules/.bin/prettier"
    if not prettier.is_file():
        prettier = PLAYBOOK_ROOT / "node_modules/.bin/prettier"
    if not prettier.is_file():
        resolved = which("prettier")
        if resolved:
            prettier = Path(resolved)
        else:
            log("Skip: prettier not found.")
            return 0

    globs_raw = config.get("globs", ["**/*.md"])
    globs = normalize_globs(globs_raw)
    result = subprocess.run(
        [str(prettier), "-w", *globs],
        cwd=project_root,
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        sys.stderr.write(result.stderr)
    return result.returncode


def run_action(name: str, config: dict, context: dict) -> int:
    print(f"[action] {name}")
    if name == "vendor":
        return vendor_action(config, context)
    if name == "sync_rules":
        return sync_rules_action(config, context)
    if name == "sync_memory_bank":
        return sync_memory_bank_action(config, context)
    if name == "sync_prompts":
        return sync_prompts_action(config, context)
    if name == "sync_standards":
        return sync_standards_action(config, context)
    if name == "install_skills":
        return install_skills_action(config, context)
    if name == "format_md":
        return format_md_action(config, context)
    return 0


def main(argv: list[str]) -> int:
    if "-h" in argv or "-help" in argv:
        print(usage())
        return 0
    if "-config" not in argv:
        print("ERROR: -config is required.\n" + usage(), file=sys.stderr)
        return 2
    idx = argv.index("-config")
    if idx + 1 >= len(argv) or not argv[idx + 1]:
        print("ERROR: -config requires a path.\n" + usage(), file=sys.stderr)
        return 2

    config_path = Path(argv[idx + 1]).expanduser()
    if not config_path.is_file():
        print(f"ERROR: config not found: {config_path}", file=sys.stderr)
        return 2

    config = load_config(config_path)
    playbook_config = config.get("playbook", {})
    project_root = playbook_config.get("project_root")
    if project_root:
        root = Path(project_root).expanduser()
        if not root.is_absolute():
            root = (config_path.parent / root).resolve()
    else:
        root = config_path.parent
    context = {
        "project_root": root.resolve(),
        "config_path": config_path.resolve(),
        "config": config,
    }

    if should_sync_agents(config):
        result = sync_agents_template(context)
        if result != 0:
            return result

    for name in ORDER:
        if name in config:
            result = run_action(name, config[name], context)
            if result != 0:
                return result

    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
