#!/usr/bin/env python3
"""把一个目录初始化成规范 context-repo：建三层骨架、按模板补固定文件。

存在的理由：骨架与固定文件每次都该长一样。交给模型逐个 Write 会漂——目录漏一个、
表格少一行、软链变成普通文件，而这些偏差要等到 `ctx resolve` 报「未找到 context-repo 根」
时才暴露。脚本把这部分做成确定性、可重跑的动作，模型只管判断产品名与模块划分。

缺失才补，已有文件一律不动，所以对已有仓可以直接重跑。

用法：
    python3 init_context_repo.py <目标目录> --product <产品名> [选项]

    --product NAME       产品名（写入 manifest.yaml，供 ws init 校验一致性）
    --description TEXT   产品说明（写入 manifest 与 README）
    --module NAME        prds/ 下的功能模块目录，可重复；不给则 prds/ 留空
    --repo-id ID         该产品的逻辑代码仓 id，可重复（仅写进文档供人查阅）
    --remote URL         远端地址（仅写进 README 供人查阅，脚本不碰 git）
    --alias NAME         产品别名，可重复（写入 manifest.product_aliases）
    --prd-layout FORM    需求产出形态：standard（走标准流程，四段式 prd/ + 命名规范）
                         或 loose（不走标准流程，只写粗略目录）。默认 standard
    --normalize-guide    把旧形态（真源在 CLAUDE.md 一侧）翻成 AGENTS.md 为真源
    --dry-run            只报计划动作，不落盘
    --json               输出 JSON

退出码：0 成功 / 1 目标不可用（不存在、非目录、形态冲突）/ 2 参数不合法
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from datetime import date
from pathlib import Path

# 三层根目录。`prds/` 与 `requirements/` 双双存在是 CLI 判定「这是 context-repo 根」
# 的唯一判据（paths.ts:isContextRepoRoot），所以它们必须物理存在、且能被 git 带走——
# 空目录 git 不跟踪，故各放一个 .gitkeep。
LAYERS = ("prds", "requirements", "assets")

# 需求产出形态 → 导航文件里 PRD 一节与 kind 表用哪份片段。
# 分两套的理由：走标准流程的仓由 `ctx scaffold` 生成四段式 `prd/`，prd-doc 落
# `02_ai_output/`；不走标准流程的仓 `prd/` 内部结构各不相同，落点也不同。
# 把不适用的那套写进导航，读的人会照着错的结构放文件。
PRD_LAYOUTS = {
    "standard": {
        "structure": "prd-standard.md",
        "kinds": "kinds-standard.md",
        "ref_example": "PRD-<YYYYMMDD>-v<X.Y>-<slug>",
        "id_example": "PRD-<YYYYMMDD>-v1.0-<slug>",
        "note": "走标准流程。PRD 由 `sdlc-cli ctx scaffold` 生成，`prd/` 为四段式"
        "（`01_origin_input` / `02_ai_output` / `03_prototype` / `04_iterate`），"
        "命名遵循 `PRD-<YYYYMMDD>-v<X.Y>-<slug>`。详见 [AGENTS.md](AGENTS.md)。",
    },
    "loose": {
        "structure": "prd-loose.md",
        "kinds": "kinds-loose.md",
        "ref_example": "<PRD-id>",
        "id_example": "<PRD-id>",
        "note": "不走标准流程。`prd/` 内部结构由本仓自己的习惯决定，无强制命名规范。"
        "硬要求只有两条：PRD 实体目录须有 `manifest.yaml`、`prds/` 下模块分组最多一层。"
        "落位路径向 `sdlc-cli ctx resolve` 要。",
    },
}

# 导航文件：`AGENTS.md` 是真源，`CLAUDE.md` 软链指向它。
# 方向不能反：AGENTS.md 是跨 AI 工具的通用约定，CLAUDE.md 只是 Claude 侧的入口名。
# 真源放通用的那个，换工具时不用动文件。sdlc-cli 仓自身即此形态。
GUIDE_SOURCE = "AGENTS.md"
GUIDE_LINK = "CLAUDE.md"
# 旧形态真源名（存量 context-repo 把真源放在 CLAUDE.md 一侧）。
LEGACY_SOURCES = (".CLAUDE.md", "CLAUDE.md")


class Planned:
    """一次初始化的动作账本：每个路径归入 created / kept / skipped 之一。"""

    def __init__(self) -> None:
        self.created: list[str] = []
        self.kept: list[str] = []
        self.skipped: list[dict] = []
        self.warnings: list[str] = []

    def skip(self, path: str, reason: str) -> None:
        self.skipped.append({"path": path, "reason": reason})


def detect_shape(root: Path) -> str:
    """判定目标目录形态，决定后续是「新建」还是「补齐」。

    返回 context-repo（三层齐备）/ partial（有部分层）/ empty / unknown。
    unknown 表示目录里有别的东西——可能拿错了目录，交给人确认，不擅自往里铺骨架。
    """
    if not any(root.iterdir()):
        return "empty"
    present = [d for d in LAYERS if (root / d).is_dir()]
    if "prds" in present and "requirements" in present:
        return "context-repo"
    if present:
        return "partial"
    visible = [p.name for p in root.iterdir() if p.name not in (".git", ".DS_Store")]
    return "empty" if not visible else "unknown"


def render(template_dir: Path, name: str, vars: dict[str, str]) -> str:
    """读模板并替换 {{VAR}} 占位符。未提供的占位符原样保留，好在校验时被看见。"""
    text = (template_dir / name).read_text(encoding="utf-8")
    for key, value in vars.items():
        text = text.replace("{{" + key + "}}", value)
    return text


def fragment(template_dir: Path, name: str) -> str:
    """读一份片段正文（导航文件里随需求形态而变的那几节）。"""
    return (template_dir / "fragments" / name).read_text(encoding="utf-8").rstrip("\n")


def write_if_absent(path: Path, content: str, plan: Planned, root: Path, dry: bool) -> None:
    """缺失才写。已有文件视为有效、原样保留——它可能是人手改过的版本。"""
    rel = str(path.relative_to(root))
    if path.exists():
        plan.kept.append(rel)
        return
    plan.created.append(rel)
    if not dry:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding="utf-8")


def ensure_dir_with_keep(root: Path, rel: str, plan: Planned, dry: bool) -> None:
    """建目录并放 .gitkeep。

    知识库那套「别用 .gitkeep 占位」的规矩在这里不适用：三层根目录是 CLI 的判根依据，
    clone 出来必须还在，而空目录进不了 git。目录里已有内容时不再补 .gitkeep。
    """
    d = root / rel
    if d.is_dir():
        plan.kept.append(rel + "/")
    else:
        plan.created.append(rel + "/")
        if not dry:
            d.mkdir(parents=True, exist_ok=True)
    has_content = d.is_dir() and any(p.name != ".gitkeep" for p in d.iterdir())
    if not has_content:
        write_if_absent(d / ".gitkeep", "", plan, root, dry)


def find_legacy_source(root: Path) -> str | None:
    """找旧形态的导航真源（`CLAUDE.md` 一侧的普通文件）。软链不算真源。"""
    for name in LEGACY_SOURCES:
        p = root / name
        if p.is_file() and not p.is_symlink():
            return name
    return None


def link_guide(root: Path, link_name: str, target: str, plan: Planned, dry: bool) -> None:
    """把 link_name 做成指向 target 的相对软链。

    只碰软链与缺失两种情况：link_name 已是普通文件时**原样保留并报出来**——那可能是
    有人手写的正文，删掉就没了，值不值得收敛由人判断。
    """
    link = root / link_name
    if link.is_symlink():
        if os.readlink(link) == target:
            plan.kept.append(link_name)
            return
        plan.created.append(f"{link_name} -> {target}（原指向 {os.readlink(link)}）")
        if not dry:
            link.unlink()
            os.symlink(target, link)
        return
    if link.exists():
        plan.skip(link_name, f"已是普通文件，未改动；如需收敛请人工把内容并入 {target} 后改软链")
        return
    plan.created.append(f"{link_name} -> {target}")
    if dry:
        return
    try:
        os.symlink(target, link)
    except OSError as e:  # Windows 无权限建软链时降级为副本，并说明差异
        plan.warnings.append(f"建软链失败（{e}），已复制 {target} 为 {link_name}；两份需人工同步")
        link.write_text((root / target).read_text(encoding="utf-8"), encoding="utf-8")


def ensure_guide(
    root: Path, template_dir: Path, vars: dict[str, str], plan: Planned, dry: bool, normalize: bool
) -> tuple[str, str]:
    """确保导航文件成形，返回（真源相对路径, 形态标签）。

    三种局面：
    1. `AGENTS.md` 已是普通文件 —— 已符合规范，保留正文，补 `CLAUDE.md` 软链。
    2. 真源在 `CLAUDE.md` 一侧（旧形态）—— 默认**不动**，只报出来。它本身能用，
       翻方向会在别人仓里产生一次改名 diff，该由人决定。`--normalize-guide` 才翻。
    3. 都没有 —— 按模板生成 `AGENTS.md`，再建 `CLAUDE.md` 软链。
    """
    src = root / GUIDE_SOURCE
    if src.is_file() and not src.is_symlink():
        plan.kept.append(GUIDE_SOURCE)
        link_guide(root, GUIDE_LINK, GUIDE_SOURCE, plan, dry)
        return GUIDE_SOURCE, "standard"

    legacy = find_legacy_source(root)
    if legacy and not normalize:
        plan.skip(
            legacy,
            f"旧形态：真源在 {legacy}，规范是 {GUIDE_SOURCE} 为真源。"
            f"重跑时加 --normalize-guide 可翻正（会产生一次改名 diff）",
        )
        link_guide(root, GUIDE_SOURCE, legacy, plan, dry)  # 至少让 AGENTS.md 指得到内容
        return legacy, "legacy"

    if legacy and normalize:
        plan.created.append(f"{GUIDE_SOURCE}（由 {legacy} 改名而来）")
        if not dry:
            old_link = root / GUIDE_SOURCE
            if old_link.is_symlink():
                old_link.unlink()  # 旧形态里 AGENTS.md 是指向 legacy 的软链，先摘掉
            (root / legacy).rename(root / GUIDE_SOURCE)
        plan.warnings.append(
            f"已把真源从 {legacy} 改名为 {GUIDE_SOURCE}。git 记为「删 {legacy} + "
            f"{GUIDE_SOURCE} 由软链转普通文件 + 新增 {GUIDE_LINK} 软链」三条改动，"
            f"提交前用 git status 核对；引用过 {legacy} 的文档需人工改指向"
        )
        link_guide(root, GUIDE_LINK, GUIDE_SOURCE, plan, dry)
        return GUIDE_SOURCE, "normalized"

    write_if_absent(src, render(template_dir, "navigation.md", vars), plan, root, dry)
    link_guide(root, GUIDE_LINK, GUIDE_SOURCE, plan, dry)
    return GUIDE_SOURCE, "standard"


def build_manifest(product: str, description: str, aliases: list[str]) -> str:
    """context-repo 根 manifest.yaml：`ws init --product` 靠它校验产品名一致性。

    字段范围由 schema/repo-manifest.ts 限死（strict）：product / product_aliases /
    description，多写一个字段会让读取直接退化成 null。
    """
    lines = [f"product: {product}"]
    if aliases:
        lines.append("product_aliases:")
        lines.extend(f"  - {a}" for a in aliases)
    if description:
        lines.append(f"description: {description}")
    return "\n".join(lines) + "\n"


def main() -> int:
    ap = argparse.ArgumentParser(description="初始化 context-repo 骨架与固定文件")
    ap.add_argument("root", help="目标目录（须已存在）")
    ap.add_argument("--product", required=True, help="产品名，写入 manifest.yaml")
    ap.add_argument("--description", default="", help="产品说明")
    ap.add_argument("--module", action="append", default=[], help="prds/ 下功能模块目录，可重复")
    ap.add_argument("--repo-id", action="append", default=[], help="逻辑代码仓 id，可重复")
    ap.add_argument("--alias", action="append", default=[], help="产品别名，可重复")
    ap.add_argument("--remote", default="", help="远端地址（仅写进 README）")
    ap.add_argument(
        "--prd-layout",
        choices=sorted(PRD_LAYOUTS),
        default="standard",
        help="需求产出形态：standard（四段式 prd/ + 命名规范）/ loose（只写粗略目录）",
    )
    ap.add_argument(
        "--normalize-guide",
        action="store_true",
        help="把旧形态（真源在 CLAUDE.md 一侧）翻成 AGENTS.md 为真源；会产生一次改名 diff",
    )
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    root = Path(args.root).expanduser().resolve()
    if not root.exists():
        return die(f"目标目录不存在: {root}", 1, args.json)
    if not root.is_dir():
        return die(f"目标不是目录: {root}", 1, args.json)
    for m in args.module:
        if "/" in m or "\\" in m:
            return die(f"模块名只接受单段（深度上限一层），收到: {m}", 2, args.json)

    shape = detect_shape(root)
    if shape == "unknown":
        names = sorted(p.name for p in root.iterdir() if p.name != ".git")[:12]
        return die(
            "目标目录已有内容且不含 prds/ 或 requirements/，无法判定是不是 context-repo。"
            f"发现: {', '.join(names)}。确认目录无误后可先手建 prds/ 再重跑。",
            1,
            args.json,
        )

    plan = Planned()
    template_dir = Path(__file__).resolve().parent.parent / "assets" / "templates"
    dry = args.dry_run

    for layer in LAYERS:
        ensure_dir_with_keep(root, layer, plan, dry)
    for m in args.module:
        ensure_dir_with_keep(root, f"prds/{m}", plan, dry)

    modules_md = (
        "\n".join(f"- `{m}/`" for m in args.module) if args.module else "- 暂无（首个 PRD 立项时再划分）"
    )
    repos_md = (
        "\n".join(f"- `{r}`" for r in args.repo_id) if args.repo_id else "- 暂无（登记代码仓后补）"
    )
    layout = PRD_LAYOUTS[args.prd_layout]
    vars = {
        "PRODUCT": args.product,
        "DESCRIPTION": args.description or args.product,
        "REMOTE": args.remote or "（未登记）",
        "DATE": date.today().isoformat(),
        "MODULES": modules_md,
        "REPOS": repos_md,
        "PRD_STRUCTURE": fragment(template_dir, layout["structure"]),
        "KIND_TABLE": fragment(template_dir, layout["kinds"]),
        "PRD_REF_EXAMPLE": layout["ref_example"],
        "PRD_ID_EXAMPLE": layout["id_example"],
        "PRD_LAYOUT_NOTE": layout["note"],
    }

    guide, guide_form = ensure_guide(
        root, template_dir, vars, plan, dry, args.normalize_guide
    )
    write_if_absent(root / "README.md", render(template_dir, "README.md", vars), plan, root, dry)
    write_if_absent(root / ".gitignore", render(template_dir, "gitignore", vars), plan, root, dry)
    write_if_absent(
        root / "manifest.yaml",
        build_manifest(args.product, args.description, args.alias),
        plan,
        root,
        dry,
    )

    result = {
        "ok": True,
        "root": str(root),
        "shape_before": shape,
        "product": args.product,
        "guide_source": guide,
        "guide_form": guide_form,
        "prd_layout": args.prd_layout,
        "modules": args.module,
        "dry_run": dry,
        "created": plan.created,
        "kept": plan.kept,
        "skipped": plan.skipped,
        "warnings": plan.warnings,
    }
    emit(result, args.json)
    return 0


def emit(result: dict, as_json: bool) -> None:
    if as_json:
        print(json.dumps(result, ensure_ascii=False, indent=2))
        return
    head = "计划" if result["dry_run"] else "已"
    print(f"{'目标':<6}{result['root']}")
    print(
        f"{'形态':<6}{result['shape_before']}  产品 {result['product']}  "
        f"需求产出 {result['prd_layout']}  导航真源 {result['guide_source']}（{result['guide_form']}）"
    )
    for label, items in (("新建", result["created"]), ("保留", result["kept"])):
        if items:
            print(f"\n{head}{label}（{len(items)}）")
            for i in items:
                print(f"  {i}")
    if result["skipped"]:
        print("\n跳过")
        for s in result["skipped"]:
            print(f"  {s['path']}: {s['reason']}")
    if result["warnings"]:
        print("\n告警")
        for w in result["warnings"]:
            print(f"  {w}")


def die(message: str, code: int, as_json: bool) -> int:
    if as_json:
        print(json.dumps({"ok": False, "error": {"code": code, "message": message}}, ensure_ascii=False, indent=2))
    else:
        print(f"错误: {message}", file=sys.stderr)
    return code


if __name__ == "__main__":
    sys.exit(main())
