"""doc_project.json 生命周期：初始化、读取、合并、快照、校验、资源扫描。"""

from __future__ import annotations

import difflib
import hashlib
import json
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable

from aicd.tools.document_outline import build_outline_payload
from aicd.tools import markdown_to_pdf as md_pdf_mod

MANIFEST_FILENAME = "doc_project.json"
SCHEMA_VERSION = 1
SYSTEM_ATTRIBUTION = "AICD"


def _under_snapshots_rel(rel: str) -> bool:
    parts = rel.replace("\\", "/").strip("/").split("/")
    return "_snapshots" in parts


IGNORE_SNAPSHOT_NAMES = frozenset(
    {"_snapshots", "tmp", "node_modules", ".git", "__pycache__"}
)

_MD_IMG = re.compile(r"!\[[^\]]*\]\(([^)]+)\)")

DEFAULT_SUBDIRS = (
    "apis",
    "diagrams",
    "outlines",
    "markdown",
    "chapters",
    "resources",
)


def library_path(ai_output: Path, doc_slug: str) -> Path:
    return (ai_output / "output" / "library" / doc_slug).resolve()


def manifest_path(lib: Path) -> Path:
    return (lib / MANIFEST_FILENAME).resolve()


def utc_now_iso() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def _ignore_for_snapshot(dirpath: str, names: list[str]) -> set[str]:
    return {n for n in names if n in IGNORE_SNAPSHOT_NAMES}


def empty_manifest(*, doc_slug: str, title: str) -> dict[str, Any]:
    return {
        "schema_version": SCHEMA_VERSION,
        "doc_slug": doc_slug,
        "title": title,
        "updated_at": utc_now_iso(),
        "assembly": None,
        "style_profile_path": None,
        "tree": [],
        "artifacts": {},
        "resources": [],
    }


def _collect_node_ids(nodes: list[dict[str, Any]], acc: list[str]) -> None:
    for n in nodes:
        nid = n.get("node_id")
        if isinstance(nid, str) and nid:
            acc.append(nid)
        ch = n.get("children")
        if isinstance(ch, list):
            _collect_node_ids(ch, acc)


def _validate_tree_nodes(
    nodes: list[dict[str, Any]], *, seen: set[str], path: str
) -> list[str]:
    errors: list[str] = []
    for i, n in enumerate(nodes):
        p = f"{path}[{i}]"
        if not isinstance(n, dict):
            errors.append(f"{p} must be object")
            continue
        nid = n.get("node_id")
        if not isinstance(nid, str) or not nid.strip():
            errors.append(f"{p}.node_id required non-empty string")
        elif nid in seen:
            errors.append(f"duplicate node_id: {nid!r}")
        else:
            seen.add(nid)
        st = n.get("status", "planned")
        if st not in ("planned", "draft", "review", "done"):
            errors.append(f"{p}.status invalid: {st!r}")
        ch = n.get("children")
        if ch is not None and not isinstance(ch, list):
            errors.append(f"{p}.children must be array")
        elif isinstance(ch, list):
            errors.extend(_validate_tree_nodes(ch, seen=seen, path=f"{p}.children"))
    return errors


def _rel_path_ok(lib: Path, rel: str) -> bool:
    if not rel or rel.startswith("..") or Path(rel).is_absolute():
        return False
    target = (lib / rel).resolve()
    try:
        target.relative_to(lib)
    except ValueError:
        return False
    return True


def doc_manifest_validate(
    lib: Path,
    manifest: dict[str, Any],
    *,
    check_paths_exist: bool = True,
) -> dict[str, Any]:
    errors: list[str] = []
    warnings: list[str] = []

    if not isinstance(manifest, dict):
        return {"ok": False, "errors": ["manifest must be object"]}

    ver = manifest.get("schema_version")
    if ver != SCHEMA_VERSION:
        errors.append(
            f"schema_version must be {SCHEMA_VERSION}, got {ver!r}"
        )

    slug = manifest.get("doc_slug")
    if not isinstance(slug, str) or not slug:
        errors.append("doc_slug required")

    tree = manifest.get("tree")
    if not isinstance(tree, list):
        errors.append("tree must be array")
        tree = []

    arts = manifest.get("artifacts")
    if arts is not None and not isinstance(arts, dict):
        errors.append("artifacts must be object")
        arts = {}

    res = manifest.get("resources")
    if res is not None and not isinstance(res, list):
        errors.append("resources must be array")
        res = []

    if isinstance(tree, list):
        errors.extend(_validate_tree_nodes(tree, seen=set(), path="tree"))

    if isinstance(arts, dict) and check_paths_exist and lib.is_dir():
        for aid, spec in arts.items():
            if not isinstance(spec, dict):
                errors.append(f"artifacts[{aid!r}] must be object")
                continue
            rp = spec.get("path")
            if isinstance(rp, str) and rp.strip():
                if not _rel_path_ok(lib, rp):
                    errors.append(f"artifacts[{aid!r}].path escapes library: {rp!r}")
                elif not (lib / rp).exists():
                    warnings.append(f"missing artifact path: {rp}")

    if isinstance(tree, list) and check_paths_exist and lib.is_dir():

        def walk(nodes: list[dict[str, Any]], prefix: str) -> None:
            for i, n in enumerate(nodes):
                if not isinstance(n, dict):
                    continue
                pfx = f"{prefix}[{i}]"
                for cp in n.get("content_paths") or []:
                    if not isinstance(cp, str):
                        errors.append(f"{pfx}.content_paths must be strings")
                        continue
                    if not _rel_path_ok(lib, cp):
                        errors.append(f"{pfx} content_path escapes library: {cp!r}")
                    elif not (lib / cp).exists():
                        warnings.append(f"missing content_path: {cp}")
                ch = n.get("children")
                if isinstance(ch, list):
                    walk(ch, f"{pfx}.children")

        walk(tree, "tree")

    return {
        "ok": len(errors) == 0,
        "errors": errors,
        "warnings": warnings,
    }


def init_doc_project(
    ai_output: Path,
    doc_slug: str,
    *,
    title: str,
    policy_check: Callable[[Path], None],
    overwrite_manifest: bool = False,
) -> dict[str, Any]:
    lib = library_path(ai_output, doc_slug)
    policy_check(lib)
    lib.mkdir(parents=True, exist_ok=True)
    for sub in DEFAULT_SUBDIRS:
        (lib / sub).mkdir(parents=True, exist_ok=True)
    mp = manifest_path(lib)
    policy_check(mp)
    if mp.is_file() and not overwrite_manifest:
        return {
            "ok": True,
            "doc_slug": doc_slug,
            "library_path": str(lib),
            "manifest_path": str(mp),
            "note": "manifest already exists; use library_doc_project_patch or overwrite_manifest",
        }
    man = empty_manifest(doc_slug=doc_slug, title=title)
    mp.write_text(
        json.dumps(man, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    idx = lib / "INDEX.md"
    if not idx.is_file():
        idx.write_text(
            f"# {title}\n\n"
            f"doc_slug: `{doc_slug}`\n\n"
            f"corpus: **{SYSTEM_ATTRIBUTION}** library layout — "
            "see `doc_project.json` for tree and content inventory.\n\n"
            "## latest\n\n"
            "(set path to current roll-up or ASSEMBLY.md)\n\n"
            "## stable\n\n"
            "(optional frozen snapshot path)\n\n"
            "## Revisions\n\n"
            "- (init)\n",
            encoding="utf-8",
        )
    return {
        "ok": True,
        "doc_slug": doc_slug,
        "library_path": str(lib),
        "manifest_path": str(mp),
        "subdirs_created": list(DEFAULT_SUBDIRS),
    }


def get_doc_project(
    ai_output: Path,
    doc_slug: str,
    *,
    policy_check: Callable[[Path], None],
) -> dict[str, Any]:
    lib = library_path(ai_output, doc_slug)
    policy_check(lib)
    mp = manifest_path(lib)
    policy_check(mp)
    if not mp.is_file():
        return {"ok": False, "error": f"no {MANIFEST_FILENAME} under {lib}"}
    try:
        data = json.loads(mp.read_text(encoding="utf-8"))
    except json.JSONDecodeError as e:
        return {"ok": False, "error": f"invalid JSON: {e}"}
    if not isinstance(data, dict):
        return {"ok": False, "error": "manifest root must be object"}
    v = doc_manifest_validate(lib, data, check_paths_exist=False)
    return {
        "ok": True,
        "library_path": str(lib),
        "manifest_path": str(mp),
        "manifest": data,
        "structure_ok": v["ok"],
        "structure_errors": v.get("errors", []),
    }


def patch_doc_project(
    ai_output: Path,
    doc_slug: str,
    updates: dict[str, Any],
    *,
    policy_check: Callable[[Path], None],
    replace_resources: bool = False,
) -> dict[str, Any]:
    lib = library_path(ai_output, doc_slug)
    policy_check(lib)
    mp = manifest_path(lib)
    policy_check(mp)
    if not mp.is_file():
        return {"ok": False, "error": f"no {MANIFEST_FILENAME}; call library_doc_project_init first"}
    try:
        cur = json.loads(mp.read_text(encoding="utf-8"))
    except json.JSONDecodeError as e:
        return {"ok": False, "error": f"invalid JSON: {e}"}
    if not isinstance(cur, dict):
        return {"ok": False, "error": "corrupt manifest"}
    for k in (
        "title",
        "assembly",
        "style_profile_path",
    ):
        if k in updates:
            cur[k] = updates[k]
    if "tree" in updates:
        cur["tree"] = updates["tree"]
    if "artifacts" in updates and isinstance(updates["artifacts"], dict):
        base = cur.get("artifacts")
        if not isinstance(base, dict):
            base = {}
        merged = {**base, **updates["artifacts"]}
        cur["artifacts"] = merged
    if "resources" in updates:
        new_r = updates["resources"]
        if replace_resources or not isinstance(cur.get("resources"), list):
            cur["resources"] = new_r if isinstance(new_r, list) else []
        elif isinstance(new_r, list):
            cur["resources"] = list(cur["resources"]) + new_r
    cur["schema_version"] = SCHEMA_VERSION
    cur["doc_slug"] = doc_slug
    cur["updated_at"] = utc_now_iso()
    val = doc_manifest_validate(lib, cur, check_paths_exist=True)
    if not val["ok"]:
        return {
            "ok": False,
            "error": "patched manifest failed validation",
            "validation_errors": val["errors"],
            "validation_warnings": val.get("warnings", []),
        }
    mp.write_text(json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8")
    return {
        "ok": True,
        "library_path": str(lib),
        "manifest_path": str(mp),
        "validation_warnings": val.get("warnings", []),
    }


def library_doc_snapshot(
    ai_output: Path,
    doc_slug: str,
    *,
    policy_check: Callable[[Path], None],
    subpath: str | None = None,
) -> dict[str, Any]:
    lib = library_path(ai_output, doc_slug)
    policy_check(lib)
    if not lib.is_dir():
        return {"ok": False, "error": f"library not found: {lib}"}
    snap_root = (lib / "_snapshots").resolve()
    policy_check(snap_root)
    snap_root.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    if subpath and str(subpath).strip():
        rel = str(subpath).strip().replace("\\", "/").strip("/")
        if ".." in rel or rel.startswith("/"):
            return {"ok": False, "error": "invalid subpath"}
        src = (lib / rel).resolve()
        try:
            src.relative_to(lib)
        except ValueError:
            return {"ok": False, "error": "subpath outside library"}
        if not src.exists():
            return {"ok": False, "error": f"subpath not found: {src}"}
        dest_parent = snap_root / stamp
        dest = dest_parent / Path(rel).name
        policy_check(dest.parent)
        dest.parent.mkdir(parents=True, exist_ok=True)
        if src.is_dir():
            shutil.copytree(src, dest, ignore=_ignore_for_snapshot)
        else:
            shutil.copy2(src, dest)
        return {
            "ok": True,
            "snapshot_dir": str(dest_parent),
            "copied": str(dest),
        }
    dest = snap_root / stamp
    policy_check(dest)
    if dest.exists():
        return {"ok": False, "error": f"snapshot path exists: {dest}"}
    shutil.copytree(lib, dest, ignore=_ignore_for_snapshot)
    nested_snap = dest / "_snapshots"
    if nested_snap.is_dir():
        shutil.rmtree(nested_snap, ignore_errors=True)
    return {
        "ok": True,
        "snapshot_dir": str(dest),
        "note": "Nested _snapshots removed inside copy to avoid recursion",
    }


def _all_leaf_nodes(
    nodes: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    for n in nodes:
        if not isinstance(n, dict):
            continue
        ch = n.get("children")
        if isinstance(ch, list) and ch:
            out.extend(_all_leaf_nodes(ch))
        else:
            out.append(n)
    return out


def doc_manifest_coverage_report(
    ai_output: Path,
    doc_slug: str,
    *,
    policy_check: Callable[[Path], None],
    scan_markdown_dir: bool = True,
) -> dict[str, Any]:
    lib = library_path(ai_output, doc_slug)
    policy_check(lib)
    mp = manifest_path(lib)
    policy_check(mp)
    if not mp.is_file():
        return {"ok": False, "error": f"no {MANIFEST_FILENAME}"}
    try:
        manifest = json.loads(mp.read_text(encoding="utf-8"))
    except json.JSONDecodeError as e:
        return {"ok": False, "error": str(e)}
    tree = manifest.get("tree")
    if not isinstance(tree, list):
        tree = []

    leaves = _all_leaf_nodes(tree)
    nodes_without_content: list[str] = []
    referenced_paths: set[str] = set()
    for leaf in leaves:
        nid = leaf.get("node_id", "?")
        paths = leaf.get("content_paths") or []
        if not paths:
            nodes_without_content.append(str(nid))
        for p in paths:
            if isinstance(p, str):
                referenced_paths.add(p.replace("\\", "/"))

    md_dir = lib / "markdown"
    md_files: list[str] = []
    if scan_markdown_dir and md_dir.is_dir():
        for f in sorted(md_dir.rglob("*.md")):
            try:
                rel = f.relative_to(lib).as_posix()
            except ValueError:
                continue
            if _under_snapshots_rel(rel):
                continue
            md_files.append(rel)

    unreferenced_md = sorted(set(md_files) - referenced_paths)

    orphan_md_not_in_tree: list[dict[str, Any]] = []
    for rel in unreferenced_md:
        fp = lib / rel
        if not fp.is_file():
            continue
        try:
            pay = build_outline_payload(fp, max_heading_level=6)
        except OSError as e:
            orphan_md_not_in_tree.append({"path": rel, "error": str(e)})
            continue
        orphan_md_not_in_tree.append(
            {
                "path": rel,
                "heading_count": pay.get("heading_count", 0),
            }
        )

    return {
        "ok": True,
        "doc_slug": doc_slug,
        "leaf_node_count": len(leaves),
        "nodes_without_content_paths": nodes_without_content,
        "referenced_content_paths": sorted(referenced_paths),
        "markdown_files_under_markdown": md_files,
        "unreferenced_markdown_files": unreferenced_md,
        "unreferenced_markdown_detail": orphan_md_not_in_tree[:200],
    }


def library_resources_scan(
    ai_output: Path,
    doc_slug: str,
    *,
    policy_check: Callable[[Path], None],
    include_snapshots: bool = False,
) -> dict[str, Any]:
    lib = library_path(ai_output, doc_slug)
    policy_check(lib)
    if not lib.is_dir():
        return {"ok": False, "error": "library not found"}

    found: dict[str, dict[str, Any]] = {}

    def add_ref(kind: str, rel: str, source: str) -> None:
        rel = rel.replace("\\", "/").strip()
        if not rel or rel.startswith("/"):
            return
        try:
            full = (lib / rel).resolve()
            full.relative_to(lib)
        except ValueError:
            return
        if not include_snapshots and _under_snapshots_rel(rel):
            return
        key = rel
        if key not in found:
            found[key] = {"path": rel, "kind": kind, "exists": full.is_file() or full.is_dir(), "refs": []}
        found[key]["refs"].append(source)

    for base in ("resources", "diagrams"):
        d = lib / base
        if not d.is_dir():
            continue
        for f in d.rglob("*"):
            if f.is_dir():
                continue
            rel = f.relative_to(lib).as_posix()
            if not include_snapshots and _under_snapshots_rel(rel):
                continue
            ext = f.suffix.lower().lstrip(".") or "file"
            add_ref(ext, rel, f"scan:{base}")

    md_roots = [lib / "markdown", lib / "chapters"]
    for root in md_roots:
        if not root.is_dir():
            continue
        for f in root.rglob("*.md"):
            rel_md = f.relative_to(lib).as_posix()
            if not include_snapshots and _under_snapshots_rel(rel_md):
                continue
            try:
                text = f.read_text(encoding="utf-8", errors="replace")
            except OSError:
                continue
            for m in _MD_IMG.finditer(text):
                u = m.group(1).strip().strip('"').strip("'").split()[0] if m.group(1) else ""
                if not u or u.startswith("http://") or u.startswith("https://"):
                    continue
                u = u.split("#")[0].strip()
                target = (f.parent / u).resolve()
                try:
                    tgt_rel = target.relative_to(lib).as_posix()
                except ValueError:
                    continue
                add_ref("img_ref", tgt_rel, f"md:{rel_md}")

    index_payload = {
        "schema_version": 1,
        "doc_slug": doc_slug,
        "updated_at": utc_now_iso(),
        "entries": sorted(found.values(), key=lambda x: x["path"]),
    }
    out_index = lib / "resources" / "index.json"
    policy_check(out_index)
    out_index.parent.mkdir(parents=True, exist_ok=True)
    out_index.write_text(
        json.dumps(index_payload, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    return {
        "ok": True,
        "library_path": str(lib),
        "index_path": str(out_index),
        "entry_count": len(found),
        "missing_paths": [e["path"] for e in found.values() if not e["exists"]],
    }


def doc_text_health_check(
    paths: list[Path],
    *,
    policy_check: Callable[[Path], None],
) -> dict[str, Any]:
    results: list[dict[str, Any]] = []
    for p in paths:
        policy_check(p)
        if not p.is_file():
            results.append({"path": str(p), "ok": False, "error": "not a file"})
            continue
        raw = p.read_bytes()
        if b"\x00" in raw[:4096]:
            results.append({"path": str(p), "ok": False, "error": "nul byte in prefix"})
            continue
        try:
            text = raw.decode("utf-8")
        except UnicodeDecodeError as e:
            results.append(
                {
                    "path": str(p),
                    "ok": False,
                    "error": f"utf-8 decode: {e}",
                }
            )
            continue
        repl = text.count("\ufffd")
        ratio = repl / max(len(text), 1)
        suspicious = ratio > 0.001 or (
            "�" in text and len(text) < 1_000_000
        )
        results.append(
            {
                "path": str(p),
                "ok": True,
                "byte_length": len(raw),
                "char_length": len(text),
                "replacement_char_count": repl,
                "replacement_ratio": round(ratio, 6),
                "suspicious_mojibake": suspicious,
            }
        )
    return {"ok": True, "files": results}


def attribution_footer_markdown(*, iso_timestamp: str | None = None) -> str:
    """本系统导出/交付材料统一署名与时间（UTC）。"""
    ts = iso_timestamp or utc_now_iso()
    return (
        f"\n\n---\n\n"
        f"*Document produced by **{SYSTEM_ATTRIBUTION}** at {ts} (UTC).*"
    )


def doc_revision_diff_summary(
    path_before: Path,
    path_after: Path,
    *,
    policy_check: Callable[[Path], None],
) -> dict[str, Any]:
    """启发式对比两版文本：长度、相似度、标题数变化；不判断是否「语义更好」。"""
    for p in (path_before, path_after):
        policy_check(p)
    if not path_before.is_file():
        return {"ok": False, "error": f"before not found: {path_before}"}
    if not path_after.is_file():
        return {"ok": False, "error": f"after not found: {path_after}"}
    tb = path_before.read_text(encoding="utf-8", errors="replace")
    ta = path_after.read_text(encoding="utf-8", errors="replace")
    hb = hashlib.sha256(tb.encode("utf-8")).hexdigest()[:16]
    ha = hashlib.sha256(ta.encode("utf-8")).hexdigest()[:16]
    ratio = round(difflib.SequenceMatcher(None, tb, ta).ratio(), 6)
    lb, la = len(tb.splitlines()), len(ta.splitlines())
    outline_delta: dict[str, Any] | None = None
    if path_before.suffix.lower() in (".md", ".markdown") and path_after.suffix.lower() in (
        ".md",
        ".markdown",
    ):
        try:
            ob = build_outline_payload(path_before, max_heading_level=6)
            oa = build_outline_payload(path_after, max_heading_level=6)
            outline_delta = {
                "heading_count_before": ob.get("heading_count"),
                "heading_count_after": oa.get("heading_count"),
            }
        except OSError:
            outline_delta = None
    uncertain = ratio > 0.92 and len(ta) < len(tb) * 0.95
    return {
        "ok": True,
        "before": str(path_before),
        "after": str(path_after),
        "char_len_before": len(tb),
        "char_len_after": len(ta),
        "line_count_before": lb,
        "line_count_after": la,
        "sha256_prefix_before": hb,
        "sha256_prefix_after": ha,
        "sequencer_similarity": ratio,
        "outline_delta": outline_delta,
        "interpretation": {
            "likely_minor_edit": ratio > 0.98,
            "likely_large_rewrite": ratio < 0.75,
            "shrink_without_big_similarity_drop": uncertain,
            "note": "Heuristic only; declare 'no improvement' only after user-aligned criteria.",
        },
    }


def _find_tree_node(
    nodes: list[dict[str, Any]], node_id: str
) -> dict[str, Any] | None:
    for n in nodes:
        if not isinstance(n, dict):
            continue
        if n.get("node_id") == node_id:
            return n
        ch = n.get("children")
        if isinstance(ch, list):
            found = _find_tree_node(ch, node_id)
            if found is not None:
                return found
    return None


def library_doc_node_resolve(
    ai_output: Path,
    doc_slug: str,
    node_id: str,
    *,
    policy_check: Callable[[Path], None],
) -> dict[str, Any]:
    lib = library_path(ai_output, doc_slug)
    policy_check(lib)
    mp = manifest_path(lib)
    policy_check(mp)
    if not mp.is_file():
        return {"ok": False, "error": f"no {MANIFEST_FILENAME}"}
    try:
        manifest = json.loads(mp.read_text(encoding="utf-8"))
    except json.JSONDecodeError as e:
        return {"ok": False, "error": str(e)}
    tree = manifest.get("tree")
    if not isinstance(tree, list):
        return {"ok": False, "error": "invalid tree"}
    node = _find_tree_node(tree, node_id.strip())
    if node is None:
        return {"ok": False, "error": f"node_id not found: {node_id!r}"}
    cps = node.get("content_paths") or []
    resolved = []
    for cp in cps:
        if isinstance(cp, str) and cp.strip():
            p = (lib / cp).resolve()
            resolved.append(
                {
                    "rel": cp.replace("\\", "/"),
                    "abs": str(p),
                    "exists": p.is_file(),
                }
            )
    return {
        "ok": True,
        "doc_slug": doc_slug,
        "node_id": node_id.strip(),
        "title": node.get("title"),
        "level": node.get("level"),
        "status": node.get("status"),
        "outline_path": node.get("outline_path"),
        "content_globs": node.get("content_globs"),
        "content_paths_resolved": resolved,
    }


def _collect_manifest_content_paths(manifest: dict[str, Any]) -> list[str]:
    out: list[str] = []
    tree = manifest.get("tree")
    if not isinstance(tree, list):
        return out

    def walk(nodes: list[dict[str, Any]]) -> None:
        for n in nodes:
            if not isinstance(n, dict):
                continue
            for cp in n.get("content_paths") or []:
                if isinstance(cp, str) and cp.strip():
                    out.append(cp.replace("\\", "/"))
            ch = n.get("children")
            if isinstance(ch, list):
                walk(ch)

    walk(tree)
    return sorted(set(out))


def library_doc_batch_quality_pass(
    ai_output: Path,
    doc_slug: str,
    *,
    policy_check: Callable[[Path], None],
    check_paths_exist: bool = True,
    run_text_health: bool = True,
    scan_resources: bool = True,
) -> dict[str, Any]:
    """一次调用：校验清单 + 覆盖率 +（可选）资源扫描 + 正文健康度。"""
    lib = library_path(ai_output, doc_slug)
    policy_check(lib)
    mp = manifest_path(lib)
    policy_check(mp)
    if not mp.is_file():
        return {"ok": False, "error": f"no {MANIFEST_FILENAME}"}
    try:
        manifest = json.loads(mp.read_text(encoding="utf-8"))
    except json.JSONDecodeError as e:
        return {"ok": False, "error": str(e)}

    val = doc_manifest_validate(
        lib, manifest, check_paths_exist=check_paths_exist
    )
    cov = doc_manifest_coverage_report(
        ai_output, doc_slug, policy_check=policy_check, scan_markdown_dir=True
    )
    res_scan: dict[str, Any] | None = None
    if scan_resources:
        res_scan = library_resources_scan(
            ai_output, doc_slug, policy_check=policy_check, include_snapshots=False
        )
    health: dict[str, Any] | None = None
    if run_text_health:
        rels = _collect_manifest_content_paths(manifest)
        paths = [lib / r for r in rels]
        existing = [p for p in paths if p.is_file()]
        if existing:
            health = doc_text_health_check(existing, policy_check=policy_check)
    v_ok = val.get("ok")
    c_ok = cov.get("ok")
    r_ok = res_scan.get("ok") if isinstance(res_scan, dict) else True
    h_ok = True
    if health and isinstance(health.get("files"), list):
        h_ok = all(
            f.get("ok") is not False and not f.get("suspicious_mojibake")
            for f in health["files"]
            if isinstance(f, dict)
        )
    return {
        "ok": bool(v_ok and c_ok and r_ok and h_ok),
        "generated_at_utc": utc_now_iso(),
        "attribution": SYSTEM_ATTRIBUTION,
        "validate": val,
        "coverage": cov,
        "resources_scan": res_scan,
        "text_health": health,
    }


def doc_project_export(
    ai_output: Path,
    doc_slug: str,
    *,
    topic_slug: str,
    source_markdown_rel: str,
    formats: list[str],
    policy_check: Callable[[Path], None],
    pandoc_extra_args: list[str] | None = None,
) -> dict[str, Any]:
    """
    将库内一篇 Markdown 导出到 ``output/deliverables/<topic_slug>/``。
    ``formats``: ``markdown`` | ``pdf`` | ``pdf_pandoc`` | ``html``（可多选）。
    导出正文末尾自动追加 **AICD** 署名与 UTC 时间。
    """
    lib = library_path(ai_output, doc_slug)
    policy_check(lib)
    rel = str(source_markdown_rel).strip().replace("\\", "/")
    if ".." in rel or rel.startswith("/"):
        return {"ok": False, "error": "invalid source_markdown_rel"}
    src = (lib / rel).resolve()
    try:
        src.relative_to(lib)
    except ValueError:
        return {"ok": False, "error": "source outside library"}
    if not src.is_file():
        return {"ok": False, "error": f"source not found: {src}"}
    if src.suffix.lower() not in (".md", ".markdown", ".txt"):
        return {"ok": False, "error": "source must be .md / .markdown / .txt"}

    deliv = (ai_output / "output" / "deliverables" / topic_slug).resolve()
    policy_check(deliv)
    deliv.mkdir(parents=True, exist_ok=True)

    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    base_stem = src.stem[:80] or "doc"
    raw_src = src.read_text(encoding="utf-8", errors="replace")
    iso = utc_now_iso()
    body = raw_src.rstrip() + attribution_footer_markdown(iso_timestamp=iso)

    outputs: list[dict[str, Any]] = []
    errs: list[str] = []

    raw_f = {str(f).strip().lower() for f in formats if str(f).strip()}
    want: set[str] = set()
    for f in raw_f:
        if f in ("markdown", "md"):
            want.add("markdown")
        elif f in ("pdf", "pdf_pymupdf"):
            want.add("pdf")
        elif f in ("pdf_pandoc",):
            want.add("pdf_pandoc")
        elif f == "html":
            want.add("html")
        else:
            return {"ok": False, "error": f"unknown format: {f!r}; use markdown|pdf|pdf_pandoc|html"}
    if not want:
        return {"ok": False, "error": "formats must be non-empty"}

    if "markdown" in want:
        out_md = deliv / f"{base_stem}_{stamp}_aicd.md"
        policy_check(out_md)
        out_md.write_text(body + "\n", encoding="utf-8")
        outputs.append({"format": "markdown", "path": str(out_md)})

    if "pdf" in want or "pdf_pymupdf" in want:
        out_pdf = deliv / f"{base_stem}_{stamp}_aicd.pdf"
        r = md_pdf_mod.export_markdown_to_pdf(
            output_pdf_path=out_pdf,
            policy_check=policy_check,
            markdown_text=body,
            engine="pymupdf",
            archive_path=lib,
            paper="a4",
            margin_pt=36.0,
        )
        if r.get("ok"):
            outputs.append({"format": "pdf", "engine": "pymupdf", "path": str(out_pdf)})
        else:
            errs.append(f"pdf: {r.get('error', r)}")

    if "pdf_pandoc" in want:
        tmp_md = deliv / f".export_{stamp}_aicd.md"
        policy_check(tmp_md)
        tmp_md.write_text(body + "\n", encoding="utf-8")
        out_pdf2 = deliv / f"{base_stem}_{stamp}_aicd_pandoc.pdf"
        r2 = md_pdf_mod.export_markdown_to_pdf(
            output_pdf_path=out_pdf2,
            policy_check=policy_check,
            markdown_path=tmp_md,
            engine="pandoc",
            pandoc_extra_args=pandoc_extra_args,
        )
        if r2.get("ok"):
            outputs.append({"format": "pdf", "engine": "pandoc", "path": str(out_pdf2)})
        else:
            errs.append(f"pdf_pandoc: {r2.get('error', r2)}")

    if "html" in want:
        frag, err = md_pdf_mod._md_to_html_fragment(body)  # noqa: SLF001
        if err or frag is None:
            errs.append(f"html: {err or 'markdown to html failed'}")
        else:
            out_html = deliv / f"{base_stem}_{stamp}_aicd.html"
            policy_check(out_html)
            html_doc = (
                "<!DOCTYPE html><html lang=\"zh-CN\"><head>"
                "<meta charset=\"utf-8\"/><title>"
                f"{SYSTEM_ATTRIBUTION} export</title></head><body>{frag}</body></html>"
            )
            out_html.write_text(html_doc, encoding="utf-8")
            outputs.append({"format": "html", "path": str(out_html)})

    man_lines = [
        f"# Deliverables ({SYSTEM_ATTRIBUTION})",
        "",
        f"- **Generated (UTC)**: {iso}",
        f"- **doc_slug**: `{doc_slug}`",
        f"- **topic_slug / deliverables dir**: `{topic_slug}`",
        f"- **Source (library-relative)**: `{rel}`",
        "",
        "## Outputs",
        "",
    ]
    for o in outputs:
        man_lines.append(f"- `{o.get('format')}` → `{o.get('path')}`")
    if errs:
        man_lines.extend(["", "## Errors", ""] + [f"- {e}" for e in errs])
    man_path = deliv / f"MANIFEST_{stamp}.md"
    policy_check(man_path)
    man_path.write_text("\n".join(man_lines) + "\n", encoding="utf-8")

    return {
        "ok": len(errs) == 0,
        "deliverables_dir": str(deliv),
        "manifest_path": str(man_path),
        "outputs": outputs,
        "errors": errs,
        "attribution": SYSTEM_ATTRIBUTION,
        "generated_at_utc": iso,
    }
