#!/usr/bin/env python3
"""Generate ``pack.yaml`` + ``README.md`` for every ``packages/*/`` tree.

Phase 4.4 of the monorepo migration (ADR-017). The manifests are
*derived* from frontmatter + ``src/config/discovery/packs.yml``; they are
regenerated on every ``task sync`` and must never be hand-edited.

For each package directory under ``packages/`` we emit:
  - ``packages/<pkg>/pack.yaml`` — id, label, owner (workspaces),
    requires, version, artefact_count
  - ``packages/<pkg>/README.md`` — pack description + per-artefact
    description table

CLI:
  --check     exit non-zero if any output would change (CI gate)
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any

import yaml

sys.path.insert(0, str(Path(__file__).resolve().parent))
from _lib.agent_src import SRC_AGENT, iter_all_sources, resolve_logical  # noqa: E402

ROOT = Path(__file__).resolve().parents[2]
PACKAGES = ROOT / "packages"
# 6.0.0-D: packs whose source has flattened into the shared library are homed
# under src/domains/<pack>/ (their pack.yaml lives there; their skills/rules
# live in the flat src/skills, src/rules library and are grouped back by
# frontmatter ``packs:``). A pack with a physical packages/<dir>/ tree keeps
# being grouped by that physical directory (byte-identical, no drift).
SRC_DOMAINS = ROOT / "src" / "domains"
# 6.0.x (ADR-052): manifest-only capability packs (no commands; artefacts grouped
# from the flat src/ library by frontmatter ``packs:``) are homed under
# src/packs/<id>/ — kept distinct from command-bearing src/domains/<pack>/ so the
# command-activation gate is never inverted (a command-less domain home would let
# a downstream installer that greps for command.md silently skip the pack).
SRC_PACKS = ROOT / "src" / "packs"
PACKS_VOCAB = ROOT / "src" / "config" / "discovery" / "packs.yml"
PACKAGE_JSON = ROOT / "package.json"
GENERATED_HEADER = "# Generated by scripts/generate_pack_manifests.py — DO NOT EDIT BY HAND\n"


def _load_yaml(path: Path) -> Any:
    if not path.exists():
        return None
    return yaml.safe_load(path.read_text(encoding="utf-8"))


def _read_frontmatter(path: Path) -> dict[str, Any]:
    text = path.read_text(encoding="utf-8")
    if not text.startswith("---"):
        return {}
    end = text.find("\n---", 4)
    if end == -1:
        return {}
    try:
        data = yaml.safe_load(text[4:end])
    except yaml.YAMLError:
        return {}
    return data if isinstance(data, dict) else {}


def _pack_id_from_dir(pkg_dir: Path) -> str:
    name = pkg_dir.name
    return "core" if name == "core" else name.removeprefix("pack-")


def _pack_homes() -> list[tuple[str, Path, str]]:
    """Every pack home as ``(pack_id, home_dir, mode)``.

    ``home_dir`` owns the pack's ``pack.yaml`` / ``README.md``. ``mode``
    selects the artefact-collection strategy:

    - ``"core"`` — the framework-neutral core pack, homed at the relocated
      container ``src/agent-src/`` (6.0.x / ADR-052). Its artefacts are the
      container's shared categories (collected by rglob, home == artefact
      root — no special whitelist) PLUS flat skills/rules tagged
      ``packs: [core]``.
    - ``"flat"`` — a pack whose artefacts live in the flat ``src/`` library,
      grouped by frontmatter ``packs:``. Used for ``src/packs/<id>/``
      (manifest-only capability packs) and ``src/domains/<pack>/``
      (command-bearing packs).
    - ``"physical"`` — a legacy ``packages/<dir>/`` tree (artefacts by rglob
      of its ``.agent-src.uncondensed/``). Kept for back-compat during the
      move window; once ``packages/`` is gone this branch yields nothing.

    Order is stable; the first home for an id wins on collision.
    """
    homes: list[tuple[str, Path, str]] = []
    seen: set[str] = set()
    # Core is homed under src/packs/core/ (like the other capability packs) — NOT
    # at src/agent-src/, because src/agent-src/ is a condense source root (prefix
    # "") and a pack.yaml/README.md there would leak into the condensed dist/agent-src/
    # output. Collection (`_collect_core`) still reads the full src/agent-src/
    # container by rglob, so home != artefact-root for core, but there is no
    # whitelist to fall out of date (ADR-052).
    if SRC_AGENT.is_dir():
        homes.append(("core", SRC_PACKS / "core", "core"))
        seen.add("core")
    for parent in (SRC_PACKS, SRC_DOMAINS):
        if not parent.is_dir():
            continue
        for d in sorted(parent.iterdir()):
            if not d.is_dir() or d.name.startswith("_"):
                continue
            pid = d.name
            if pid in seen:
                continue
            homes.append((pid, d, "flat"))
            seen.add(pid)
    if PACKAGES.exists():
        for pkg in sorted(PACKAGES.iterdir()):
            if not pkg.is_dir():
                continue
            pid = _pack_id_from_dir(pkg)
            if pid in seen:
                continue
            homes.append((pid, pkg, "physical"))
            seen.add(pid)
    return homes


def _vocab_lookup(packs_vocab: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
    return {p["id"]: p for p in (packs_vocab or [])}


def _package_version() -> str:
    if not PACKAGE_JSON.exists():
        return "0.0.0"
    return json.loads(PACKAGE_JSON.read_text(encoding="utf-8")).get("version", "0.0.0")


def _artefact_record(path: Path, logical_rel: str, fm: dict[str, Any]) -> dict[str, Any]:
    return {
        "path": logical_rel,
        "name": fm.get("name") or (path.parent.name if path.name == "SKILL.md" else path.stem),
        "description": (fm.get("description") or "").strip(),
        "category": _category_from_logical(logical_rel),
        # Include edges — the skills / rules this artefact references in its
        # frontmatter. Drives the generated `dependencies` block + the
        # dependency lint (road-to-6.0.0-D Phase 0 Steps 2-3).
        "skills": [s for s in (fm.get("skills") or []) if isinstance(s, str)],
        "rules": [r for r in (fm.get("rules") or []) if isinstance(r, str)],
    }


def _collect_physical(pkg_dir: Path) -> list[dict[str, Any]]:
    """Collect a physical ``packages/<dir>/`` pack's artefacts (path-grouped)."""
    src_root = pkg_dir / ".agent-src.uncondensed"
    if not src_root.is_dir():
        return []
    items: list[dict[str, Any]] = []
    for p in sorted(src_root.rglob("*.md")):
        if not p.is_file():
            continue
        fm = _read_frontmatter(p)
        if not fm:
            continue
        items.append(_artefact_record(p, p.relative_to(src_root).as_posix(), fm))
    return items


def _collect_core() -> list[dict[str, Any]]:
    """Collect the core pack's artefacts: the ``src/agent-src/`` container's
    shared categories (rglob — home == artefact root) PLUS flat skills/rules
    tagged ``packs: [core]``. Deduped by logical relpath.

    6.0.x (ADR-052): core is homed at ``src/agent-src/``; its shared
    categories carry no ``packs:`` axis (they are core-owned by location), so
    they are collected by rglob, while core's skills/rules live in the flat
    library and carry ``packs: [core]``.
    """
    items: list[dict[str, Any]] = []
    seen: set[str] = set()
    if SRC_AGENT.is_dir():
        for p in sorted(SRC_AGENT.rglob("*.md")):
            if not p.is_file():
                continue
            fm = _read_frontmatter(p)
            if not fm:
                continue
            logical = p.relative_to(SRC_AGENT).as_posix()
            seen.add(logical)
            items.append(_artefact_record(p, logical, fm))
    for rec in _collect_flat("core"):
        if rec["path"] not in seen:
            seen.add(rec["path"])
            items.append(rec)
    return items


def _collect_flat(pid: str) -> list[dict[str, Any]]:
    """Collect a flattened pack's artefacts from the shared ``src/`` library.

    Membership is the frontmatter ``packs:`` axis (6.0.0-D council
    decision): an artefact belongs to ``pid`` iff ``pid in fm['packs']``.
    Commands physically nested under ``src/domains/<pid>/`` also belong.
    """
    items: list[dict[str, Any]] = []
    seen: set[str] = set()
    for phys, logical in iter_all_sources():
        if not logical.endswith(".md") or logical in seen:
            continue
        fm = _read_frontmatter(phys)
        if not fm:
            continue
        if pid in (fm.get("packs") or []):
            seen.add(logical)
            items.append(_artefact_record(phys, logical, fm))
    return sorted(items, key=lambda a: a["path"])


def _collect_artefacts(pid: str, home_dir: Path, mode: str) -> list[dict[str, Any]]:
    if mode == "core":
        return _collect_core()
    if mode == "physical":
        return _collect_physical(home_dir)
    return _collect_flat(pid)


def _category_from_logical(logical_rel: str) -> str:
    top = logical_rel.split("/", 1)[0]
    mapping = {"skills": "skill", "rules": "rule", "commands": "command",
               "personas": "persona", "guidelines": "guideline",
               "contexts": "context", "presets": "preset", "profiles": "profile"}
    return mapping.get(top, top)


def _build_pack_yaml(pid: str, vocab: dict[str, dict[str, Any]],
                     artefacts: list[dict[str, Any]], version: str) -> dict[str, Any]:
    meta = vocab.get(pid) or {}
    label = meta.get("label") or (pid.title() if pid == "core" else pid)
    out: dict[str, Any] = {
        "id": pid,
        "label": label,
        "description": meta.get("description") or ("Core framework-neutral artefacts." if pid == "core" else ""),
        "owner": meta.get("workspaces") or (["agent-config-maintainer"] if pid == "core" else []),
        "requires": meta.get("requires") or meta.get("requires_hint") or [],
        "trust_level_default": meta.get("trust_level_default") or "core",
        "version": version,
        "artefact_count": len(artefacts),
    }
    if isinstance(meta.get("onboarding"), dict):
        out["onboarding"] = meta["onboarding"]
    # slug_prefix — opt-in command-slug prefix (ADR-044 amendment A3). Sourced
    # from the discovery vocab; absent = no prefix (the default). The canonical
    # slug deriver (scripts/_lib/agent_src.command_slug) reads this manifest.
    if meta.get("slug_prefix"):
        out["slug_prefix"] = meta["slug_prefix"]
    # size_class / suggests come from the discovery vocab (src/config/discovery/packs.yml)
    # when defined there; the pack.yaml schema (scripts/schemas/pack.schema.json)
    # treats them as optional so reserved-vocab packs without artefacts still validate.
    if meta.get("size_class"):
        out["size_class"] = meta["size_class"]
    out["suggests"] = meta.get("suggests") or []
    # dependencies — the artefact-level include graph (skills / rules the pack's
    # commands reference). Generated from frontmatter so it stays in sync; the
    # dependency lint re-derives this and fails on drift, validate_pack_yaml.py
    # fails on a reference that no longer resolves (road-to-6.0.0-D Phase 0).
    dep_skills: set[str] = set()
    dep_rules: set[str] = set()
    for a in artefacts:
        dep_skills.update(a.get("skills") or [])
        dep_rules.update(a.get("rules") or [])
    out["dependencies"] = {
        "skills": sorted(dep_skills),
        "rules": sorted(dep_rules),
    }
    return out


def _render_readme(pack_meta: dict[str, Any], artefacts: list[dict[str, Any]]) -> str:
    lines = [GENERATED_HEADER, f"# {pack_meta['label']}", ""]
    if pack_meta.get("description"):
        lines += [pack_meta["description"], ""]
    lines += [f"- **id**: `{pack_meta['id']}`",
              f"- **version**: `{pack_meta['version']}`",
              f"- **owner**: {', '.join(pack_meta['owner']) or '—'}",
              f"- **requires**: {', '.join(pack_meta['requires']) or '—'}",
              f"- **artefacts**: {pack_meta['artefact_count']}", ""]
    by_cat: dict[str, list[dict[str, Any]]] = {}
    for a in artefacts:
        by_cat.setdefault(a["category"], []).append(a)
    for cat in sorted(by_cat):
        lines += [f"## {cat.title()}s ({len(by_cat[cat])})", ""]
        for a in sorted(by_cat[cat], key=lambda x: x["name"]):
            desc = a["description"] or "_(no description)_"
            lines.append(f"- **`{a['name']}`** — {desc}")
        lines.append("")
    return "\n".join(lines).rstrip() + "\n"


def _write_if_changed(path: Path, content: str, check: bool) -> bool:
    existing = path.read_text(encoding="utf-8") if path.exists() else None
    if existing == content:
        return False
    if check:
        return True
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")
    return True


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--check", action="store_true", help="exit non-zero on drift")
    args = ap.parse_args()
    vocab = _vocab_lookup(_load_yaml(PACKS_VOCAB) or [])
    version = _package_version()
    homes = _pack_homes()
    if not homes:
        print("no pack homes (src/agent-src, src/packs, src/domains, packages/) — nothing to generate", file=sys.stderr)
        return 0
    drift = 0
    for pid, home_dir, mode in homes:
        artefacts = _collect_artefacts(pid, home_dir, mode)
        meta = _build_pack_yaml(pid, vocab, artefacts, version)
        yaml_body = GENERATED_HEADER + yaml.safe_dump(meta, sort_keys=True, allow_unicode=True)
        readme_body = _render_readme(meta, artefacts)
        label = home_dir.relative_to(ROOT).as_posix()
        if _write_if_changed(home_dir / "pack.yaml", yaml_body, args.check):
            drift += 1
            print(f"{'drift' if args.check else 'wrote'}: {label}/pack.yaml")
        if _write_if_changed(home_dir / "README.md", readme_body, args.check):
            drift += 1
            print(f"{'drift' if args.check else 'wrote'}: {label}/README.md")
    if args.check and drift:
        print(f"pack manifests out of date ({drift} file(s)) — run `task generate-pack-manifests`", file=sys.stderr)
        return 1
    return 0


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