#!/usr/bin/env python3
"""ARIS verification-space helper for the verify-manager skill.

A verification lives in `_verify-` prefixed mirrors of mainline directories:
    _verify-idea-stage/<slug>/   HYPOTHESIS.md, RESULT.md, NEXT_IDEAS.md, STATUS.json (anchor)
    _verify-refine-logs/<slug>/  PILOT_PLAN.md, PILOT_TASKS.md
    _verify-<dir>/<slug>/        experiment code and outputs, created on demand
Mainline directories are never written. Every command prints one JSON object.
"""

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

PREFIX = "_verify-"
ANCHOR_DIR = "idea-stage"
PLAN_DIR = "refine-logs"
STATUS_FILE = "STATUS.json"
SNAPSHOT_FILE = ".mainline_snapshot.json"
TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
# 生命周期产物：(键, 镜像所属主线目录, 文件名)，顺序即阶段顺序
DOCS = (("hypothesis", ANCHOR_DIR, "HYPOTHESIS.md"), ("plan", PLAN_DIR, "PILOT_PLAN.md"),
        ("tasks", PLAN_DIR, "PILOT_TASKS.md"), ("result", ANCHOR_DIR, "RESULT.md"),
        ("next", ANCHOR_DIR, "NEXT_IDEAS.md"))
# 主线 ARIS 产物：验证期间应保持不变，结项时与快照比对
MAINLINE_DIRS = ("idea-stage", "refine-logs", "review-stage", "paper", "research-wiki", "pilot", "src", "results")
MAINLINE_FILES = ("IDEA_REPORT.md", "EXPERIMENT_PLAN.md", "EXPERIMENT_LOG.md", "NARRATIVE_REPORT.md",
                  "AUTO_REVIEW.md", "REVIEW_STATE.json", "findings.md")
SNAPSHOT_CAP = 5000  # 单个目录超过这么多文件时不做逐文件比对，记为 unchecked
SKIP_PARTS = {".git", "__pycache__", "node_modules", ".venv", "venv", ".ipynb_checkpoints"}
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
DIR_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
TASK_RE = re.compile(r"^\s*-\s*\[([ xX!-])\]\s*(T\d{3,})\b(.*)$")
GATE_RE = re.compile(r"^\s*-\s*\[([ xX-])\]\s*(G\d+)\b")
TAG_RE = re.compile(r"\[(SU|BL|TR|AN)\]")
VERDICTS = ("supported", "refuted", "inconclusive")
PROMOTE_TARGETS = ("idea-discovery", "research-refine", "research-refine-pipeline", "experiment-plan")
TODO = "【TODO】"
CLARIFY = "【待澄清】"
TASK_STATE = {" ": "pending", "x": "done", "X": "done", "-": "skipped", "!": "failed"}
NEXT_HINT = {
    "specify": "read phases/1-specify.md; fill HYPOTHESIS.md and resolve every clarification",
    "plan": "read phases/2-plan.md; fill PILOT_PLAN.md and resolve every research-integrity gate",
    "tasks": "read phases/3-tasks.md; write tasks into PILOT_TASKS.md",
    "run": "read phases/4-run.md; execute the pending tasks",
    "analyze": "read phases/5-analyze.md; analyze against the baseline into RESULT.md",
    "next": "read phases/6-next.md; propose next ideas in NEXT_IDEAS.md",
    "conclude": "run: conclude <slug> --verdict ... --summary ...",
    "decide": "report to the user: promote, start a child verification (new --parent), or archive",
    "closed": "nothing left to do",
}


class VerifyError(Exception):
    pass


def now():
    return datetime.now().astimezone().isoformat(timespec="seconds")


def resolve_root(raw):
    root = Path(raw).expanduser().resolve()
    if not root.is_dir():
        raise VerifyError(f"project root does not exist: {root}")
    if root.parent == root:
        raise VerifyError(f"refusing to use a filesystem root as project root: {root}")
    # ARIS 仓库本身不是研究项目，往里写验证产物会污染上游克隆
    if (root / "AGENT_GUIDE.md").is_file() and (root / "skills").is_dir() and (root / "tools").is_dir():
        raise VerifyError(f"{root} looks like the ARIS repository itself, not a research project; pass --root <project>")
    return root


def mirror(root, mainline, slug):
    if not DIR_RE.match(mainline) or mainline.startswith(PREFIX):
        raise VerifyError(f"invalid mainline directory name: {mainline!r}")
    return root / f"{PREFIX}{mainline}" / slug


def anchor(root, slug):
    return mirror(root, ANCHOR_DIR, slug)


def rel(root, path):
    return path.relative_to(root).as_posix()


def doc_paths(root, slug):
    return {key: mirror(root, d, slug) / name for key, d, name in DOCS}


def read(path):
    return path.read_text(encoding="utf-8") if path.is_file() else None


def render(name, fields):
    path = TEMPLATE_DIR / name
    if not path.is_file():
        raise VerifyError(f"template missing: {path}")
    text = path.read_text(encoding="utf-8")
    for key, value in fields.items():
        text = text.replace("{{" + key + "}}", str(value))
    return text


def load(root, slug):
    if not SLUG_RE.match(slug):
        raise VerifyError(f"invalid slug: {slug!r}")
    status = anchor(root, slug) / STATUS_FILE
    if not status.is_file():
        raise VerifyError(f"no verification named {slug!r} (expected {rel(root, status)})")
    return json.loads(status.read_text(encoding="utf-8"))


def save(root, state):
    path = anchor(root, state["slug"]) / STATUS_FILE
    tmp = path.with_name(STATUS_FILE + ".tmp")
    tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    os.replace(tmp, path)


def scan(root, names):
    """Record size and mtime of mainline files; oversized trees are returned as unchecked."""
    files, unchecked = {}, []
    for name in names:
        target = root / name
        if target.is_file():
            st = target.stat()
            files[name] = [st.st_size, st.st_mtime_ns]
            continue
        if not target.is_dir():
            continue
        found = {}
        for dirpath, dirnames, filenames in os.walk(target):
            dirnames[:] = [d for d in dirnames if d not in SKIP_PARTS]
            for fn in filenames:
                p = Path(dirpath) / fn
                try:
                    st = p.stat()
                except OSError:
                    continue
                found[rel(root, p)] = [st.st_size, st.st_mtime_ns]
            if len(found) > SNAPSHOT_CAP:
                break
        if len(found) > SNAPSHOT_CAP:
            unchecked.append(name)
        else:
            files.update(found)
    return files, unchecked


def read_snapshot(root, slug):
    return json.loads((anchor(root, slug) / SNAPSHOT_FILE).read_text(encoding="utf-8"))


def write_snapshot(root, slug, snap):
    (anchor(root, slug) / SNAPSHOT_FILE).write_text(json.dumps(snap, ensure_ascii=False), encoding="utf-8")


def progress(root, slug, state):
    """Per-document completeness, gate and task counts, and the lifecycle phase they imply."""
    paths = doc_paths(root, slug)
    docs = {}
    for key, path in paths.items():
        text = read(path)
        docs[key] = {"path": rel(root, path), "exists": text is not None,
                     "todo": text.count(TODO) if text else 0, "clarify": text.count(CLARIFY) if text else 0}
    plan_text = read(paths["plan"]) or ""
    gates = [m.group(1) for m in (GATE_RE.match(line) for line in plan_text.splitlines()) if m]
    gates_open = sum(1 for g in gates if g == " ")
    tasks = {"pending": 0, "done": 0, "skipped": 0, "failed": 0, "total": 0,
             "pending_non_an": 0, "pending_an": 0, "failed_bl": 0, "next_task": None}
    for line in (read(paths["tasks"]) or "").splitlines():
        m = TASK_RE.match(line)
        if not m:
            continue
        state_name = TASK_STATE[m.group(1)]
        tag = TAG_RE.search(m.group(3))
        tag = tag.group(1) if tag else None
        tasks[state_name] += 1
        tasks["total"] += 1
        if state_name == "pending":
            tasks["pending_an" if tag == "AN" else "pending_non_an"] += 1
            if tasks["next_task"] is None:
                tasks["next_task"] = m.group(2)
        if state_name == "failed" and tag == "BL":
            tasks["failed_bl"] += 1

    def unfinished(key):
        d = docs[key]
        return not d["exists"] or d["todo"] > 0 or d["clarify"] > 0

    if state["status"] in ("promoted", "archived"):
        phase = "closed"
    elif state["status"] == "concluded":
        phase = "decide"
    elif unfinished("hypothesis"):
        phase = "specify"
    elif unfinished("plan") or gates_open > 0 or not gates:
        phase = "plan"
    elif unfinished("tasks") or tasks["total"] == 0:
        phase = "tasks"
    elif tasks["pending_non_an"] > 0:
        phase = "run"
    elif tasks["pending_an"] > 0 or unfinished("result"):
        phase = "analyze"
    elif unfinished("next"):
        phase = "next"
    else:
        phase = "conclude"
    return {"phase": phase, "next": NEXT_HINT[phase], "docs": docs,
            "gates": {"total": len(gates), "open": gates_open}, "tasks": tasks}


def cmd_new(args, root):
    title = args.title.strip()
    if not title:
        raise VerifyError("--title must not be empty")
    parent = load(root, args.parent) if args.parent else None
    base = args.slug or datetime.now().strftime("v%Y%m%d-%H%M")
    if not SLUG_RE.match(base):
        raise VerifyError(f"invalid slug {base!r}: use lowercase letters, digits and hyphens")
    slug, n = base, 2
    while anchor(root, slug).exists():
        slug, n = f"{base}-{n}", n + 1
    created = now()
    paths = doc_paths(root, slug)
    fields = {"title": title, "slug": slug, "created": created, "source": args.source or TODO,
              "parent": f"`{parent['slug']}`" if parent else "无",
              "hypothesis": rel(root, paths["hypothesis"]), "plan": rel(root, paths["plan"]),
              "result": rel(root, paths["result"])}
    # 先渲染全部模板，模板缺失时不留下半成品目录
    rendered = {key: render(path.name, fields) for key, path in paths.items()}
    idea, plan = anchor(root, slug), mirror(root, PLAN_DIR, slug)
    idea.mkdir(parents=True)
    plan.mkdir(parents=True, exist_ok=True)
    for key, path in paths.items():
        path.write_text(rendered[key], encoding="utf-8")
    names = list(MAINLINE_DIRS + MAINLINE_FILES)
    files, unchecked = scan(root, names)
    write_snapshot(root, slug, {"names": names, "files": files, "unchecked": unchecked})
    state = {
        "slug": slug, "title": title, "status": "open", "verdict": None, "summary": None,
        "assurance": "self-assessed", "source": args.source, "parent": parent["slug"] if parent else None,
        "children": [], "created": created, "updated": created,
        "dirs": [rel(root, idea), rel(root, plan)], "mainline_touched": None, "promotion": None,
        "history": [{"at": created, "event": "created"}],
    }
    save(root, state)
    if parent:
        parent.setdefault("children", []).append(slug)
        parent["updated"] = created
        parent["history"].append({"at": created, "event": "child", "slug": slug})
        save(root, parent)
    out = {"slug": slug, "phase": "specify", "next": NEXT_HINT["specify"],
           "docs": {key: rel(root, path) for key, path in paths.items()}}
    if parent:
        out["parent"] = parent["slug"]
    if not any((root / name).exists() for name in names):
        out["warning"] = "no ARIS mainline found at this root; confirm it is the intended project"
    return out


def cmd_check(args, root):
    state = load(root, args.slug)
    return {"slug": args.slug, "status": state["status"], "verdict": state["verdict"],
            **progress(root, args.slug, state)}


def cmd_dir(args, root):
    state = load(root, args.slug)
    path = mirror(root, args.mainline, args.slug)
    path.mkdir(parents=True, exist_ok=True)
    r = rel(root, path)
    if r not in state["dirs"]:
        state["dirs"].append(r)
        state["updated"] = now()
        save(root, state)
    # 新的实验目录也纳入主线快照，结项时一并比对
    snap = read_snapshot(root, args.slug)
    if args.mainline not in snap["names"]:
        files, unchecked = scan(root, [args.mainline])
        snap["names"].append(args.mainline)
        snap["files"].update(files)
        snap["unchecked"].extend(unchecked)
        write_snapshot(root, args.slug, snap)
    return {"slug": args.slug, "path": r, "mainline": args.mainline}


def cmd_list(args, root):
    items = []
    base = root / f"{PREFIX}{ANCHOR_DIR}"
    if base.is_dir():
        for status in sorted(base.glob(f"*/{STATUS_FILE}")):
            try:
                s = json.loads(status.read_text(encoding="utf-8"))
            except (OSError, ValueError) as exc:
                items.append({"slug": status.parent.name, "error": f"unreadable {STATUS_FILE}: {exc}"})
                continue
            if s.get("status") == "archived" and not args.all:
                continue
            item = {k: s.get(k) for k in ("slug", "title", "status", "verdict", "summary", "parent", "updated")}
            item["phase"] = progress(root, s["slug"], s)["phase"]
            items.append(item)
    return {"count": len(items), "verifications": items}


def cmd_show(args, root):
    state = load(root, args.slug)
    files = []
    for d in state["dirs"]:
        p = root / d
        if p.is_dir():
            files += [rel(root, f) for f in sorted(p.rglob("*")) if f.is_file() and f.name != SNAPSHOT_FILE]
    return {"verification": state, "progress": progress(root, args.slug, state),
            "files": files[:200], "files_total": len(files)}


def cmd_conclude(args, root):
    state = load(root, args.slug)
    if state["status"] in ("promoted", "archived"):
        raise VerifyError(f"{args.slug} is already {state['status']}")
    prog = progress(root, args.slug, state)
    problems = []
    for key, d in prog["docs"].items():
        if not d["exists"]:
            problems.append(f"missing {d['path']}")
        elif d["todo"] or d["clarify"]:
            problems.append(f"{d['path']} still has {d['todo']} {TODO} and {d['clarify']} {CLARIFY} markers")
    if prog["gates"]["total"] == 0:
        problems.append("PILOT_PLAN.md has no research-integrity gates")
    elif prog["gates"]["open"]:
        problems.append(f"{prog['gates']['open']} research-integrity gate(s) still unchecked")
    t = prog["tasks"]
    if t["total"] == 0:
        problems.append("PILOT_TASKS.md has no tasks")
    elif t["pending"]:
        problems.append(f"{t['pending']} task(s) still pending (next: {t['next_task']})")
    if args.verdict == "supported" and t["failed_bl"]:
        problems.append("a baseline task failed, so the verdict cannot be supported; use inconclusive")
    if problems:
        raise VerifyError("; ".join(problems))
    snap = read_snapshot(root, args.slug)
    files, _ = scan(root, snap["names"])
    files = {k: v for k, v in files.items() if k.split("/")[0] not in snap["unchecked"]}
    before = snap["files"]
    touched = sorted({k for k in before.keys() & files.keys() if before[k] != files[k]}
                     | (files.keys() - before.keys()) | (before.keys() - files.keys()))
    when = now()
    state.update(status="concluded", verdict=args.verdict, summary=args.summary,
                 mainline_touched=bool(touched), updated=when)
    state["history"].append({"at": when, "event": "concluded", "verdict": args.verdict})
    save(root, state)
    return {"slug": args.slug, "verdict": args.verdict, "tasks": t, "mainline_touched": bool(touched),
            "mainline_changes": touched[:20], "mainline_changes_total": len(touched),
            "mainline_unchecked": snap["unchecked"], "next": NEXT_HINT["decide"]}


def cmd_promote(args, root):
    state = load(root, args.slug)
    if state["status"] == "archived":
        raise VerifyError(f"{args.slug} is archived")
    if state["status"] == "promoted":
        raise VerifyError(f"{args.slug} was already promoted ({state['promotion']['file']})")
    eligible = state["status"] == "concluded" and state["verdict"] == "supported"
    if not eligible:
        if not args.force:
            raise VerifyError(f"promotion needs a concluded, supported verification "
                              f"(status={state['status']}, verdict={state['verdict']}); use --force --reason to override")
        if not (args.reason or "").strip():
            raise VerifyError("--force requires --reason")
    paths = doc_paths(root, args.slug)
    when = now()
    promotion = anchor(root, args.slug) / "PROMOTION.md"
    promotion.write_text(render("PROMOTION.md", {
        "title": state["title"], "slug": args.slug, "verdict": state["verdict"] or "无",
        "result": rel(root, paths["result"]), "plan": rel(root, paths["plan"]), "tasks": rel(root, paths["tasks"]),
        "target": args.to, "when": when,
        "forced": f"- 强制转正原因: {args.reason}\n" if not eligible else ""}), encoding="utf-8")
    state.update(status="promoted", updated=when)
    state["promotion"] = {"to": args.to, "at": when, "file": rel(root, promotion),
                          "forced": not eligible, "reason": args.reason}
    state["history"].append({"at": when, "event": "promoted", "to": args.to})
    save(root, state)
    return {"slug": args.slug, "promotion": rel(root, promotion), "to": args.to,
            "next": f"fill the {TODO} sections of PROMOTION.md, show it to the user, "
                    "and run the mainline skill only after they confirm"}


def cmd_archive(args, root):
    state = load(root, args.slug)
    if state["status"] == "archived":
        return {"slug": args.slug, "status": "archived", "note": "already archived"}
    when = now()
    state.update(status="archived", updated=when, archive_reason=args.reason)
    state["history"].append({"at": when, "event": "archived", "reason": args.reason})
    save(root, state)
    return {"slug": args.slug, "status": "archived"}


def main(argv=None):
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--root", default=".", help="research project root (default: current directory)")
    parser = argparse.ArgumentParser(prog="verify_space.py", description="ARIS _verify- verification space")
    sub = parser.add_subparsers(dest="command", required=True)
    p = sub.add_parser("new", parents=[common], help="create a verification and render its templates")
    p.add_argument("--title", required=True)
    p.add_argument("--slug")
    p.add_argument("--source")
    p.add_argument("--parent", help="slug of the verification this one follows up")
    p = sub.add_parser("check", parents=[common], help="report progress and the current phase")
    p.add_argument("slug")
    p = sub.add_parser("dir", parents=[common], help="create or get the _verify- mirror of a mainline directory")
    p.add_argument("slug")
    p.add_argument("mainline")
    p = sub.add_parser("list", parents=[common], help="list verifications")
    p.add_argument("--all", action="store_true", help="include archived")
    p = sub.add_parser("show", parents=[common], help="show one verification")
    p.add_argument("slug")
    p = sub.add_parser("conclude", parents=[common], help="record the verdict")
    p.add_argument("slug")
    p.add_argument("--verdict", required=True, choices=VERDICTS)
    p.add_argument("--summary", required=True)
    p = sub.add_parser("promote", parents=[common], help="write the mainline handoff")
    p.add_argument("slug")
    p.add_argument("--to", required=True, choices=PROMOTE_TARGETS)
    p.add_argument("--force", action="store_true")
    p.add_argument("--reason")
    p = sub.add_parser("archive", parents=[common], help="archive a verification")
    p.add_argument("slug")
    p.add_argument("--reason")
    args = parser.parse_args(argv)
    handlers = {"new": cmd_new, "check": cmd_check, "dir": cmd_dir, "list": cmd_list, "show": cmd_show,
                "conclude": cmd_conclude, "promote": cmd_promote, "archive": cmd_archive}
    try:
        result = handlers[args.command](args, resolve_root(args.root))
    except VerifyError as exc:
        print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
        return 1
    print(json.dumps({"ok": True, **result}, ensure_ascii=False, indent=2))
    return 0


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