#!/usr/bin/env python3
# Copyright (c) 2026 Carnegie Mellon University
# SPDX-License-Identifier: BSD-3-Clause-Clear
"""Generate the docs-site module/stack catalog from the registry index.

RFC #379 §9 ("Docs"): the main site owns an auto-generated marketplace
catalog rendered from the `airstack-modules-index` registry repo
(https://github.com/castacks/airstack-modules-index). This script reads a
LOCAL CHECKOUT of that registry (the docs deploy workflows shallow-clone it;
developers point at any clone) and emits deterministic Markdown pages under
``docs/modules/``:

* ``docs/modules/index.md`` — the catalog: one table row per registered
  module (name, description, type, maintainer, DECLARED compat, links) and
  one per registered stack.
* ``docs/modules/<name>.md`` — one page per module: description, install
  snippet, maintainer, DECLARED-vs-VERIFIED compatibility note pointing at
  the registry's ``compat/`` matrix, and a link to the module README on
  GitHub at the registered ref.

The pages are committed so the site never depends on registry availability;
the deploy workflows regenerate them against the live registry at build time
(failure-isolated: an unreachable registry or module repo falls back to the
committed pages / a stub note — RFC #379 §9).

Determinism contract: byte-identical output for identical inputs (registry
checkout + trunk tree + fetched-modules dir). No timestamps, no environment
leakage. ``--check`` regenerates into a temp dir and diffs against the
committed pages (CI drift style; exit 1 on drift).

stdlib + PyYAML only.
"""
from __future__ import annotations

import argparse
import difflib
import re
import sys
import tempfile
from pathlib import Path

import yaml

TRUNK = Path(__file__).resolve().parent.parent
REGISTRY_URL = "https://github.com/castacks/airstack-modules-index"

GENERATED_MARKER = (
    "<!-- GENERATED by tools/gen_docs_catalog.py from the "
    "airstack-modules-index registry. Do not edit by hand: regenerate with\n"
    "     python3 tools/gen_docs_catalog.py --index <registry-checkout>\n"
    "     (the docs deploy workflows regenerate it against the live registry "
    "at build time) -->"
)


# ---------------------------------------------------------------- helpers


def _die(msg: str) -> "NoReturn":  # noqa: F821 (py<3.11 typing)
    print(f"gen_docs_catalog: error: {msg}", file=sys.stderr)
    sys.exit(2)


def _load_yaml(path: Path) -> dict:
    data = yaml.safe_load(path.read_text())
    if not isinstance(data, dict):
        _die(f"{path} did not parse to a YAML mapping")
    return data


def _load_entries(directory: Path) -> "list[dict]":
    """Load all registry entries in a directory, sorted by name."""
    entries = []
    if not directory.is_dir():
        return entries
    for path in sorted(directory.glob("*.yaml")):
        entry = _load_yaml(path)
        entry.setdefault("name", path.stem)
        entries.append(entry)
    entries.sort(key=lambda e: e["name"])
    return entries


def _norm_repo_url(url: str) -> str:
    """Normalize a git URL for equality checks (ssh/https, .git suffix)."""
    url = url.strip()
    m = re.match(r"^git@([^:]+):(.+)$", url)
    if m:
        url = f"https://{m.group(1)}/{m.group(2)}"
    if url.endswith(".git"):
        url = url[:-4]
    return url.rstrip("/")


def _repo_slug(url: str) -> str:
    """castacks/asm_optitrack from any GitHub URL form; else the URL."""
    norm = _norm_repo_url(url)
    m = re.match(r"^https://github\.com/(.+)$", norm)
    return m.group(1) if m else norm


def _short_ref(ref: str) -> str:
    return ref[:12] if re.fullmatch(r"[0-9a-f]{40}", ref) else ref


def _md_cell(text: str) -> str:
    """Collapse whitespace and escape pipes for a Markdown table cell."""
    return " ".join(str(text).split()).replace("|", "\\|")


def _stack_pins(stack_dir: Path) -> "list[str]":
    """Normalized module-repo URLs pinned by a trunk stack's modules.repos."""
    repos_file = stack_dir / "modules.repos"
    if not repos_file.is_file():
        return []
    try:
        data = yaml.safe_load(repos_file.read_text()) or {}
    except yaml.YAMLError:
        return []
    repositories = data.get("repositories") or {}
    if not isinstance(repositories, dict):
        return []
    return sorted(
        _norm_repo_url(str(spec.get("url", "")))
        for spec in repositories.values()
        if isinstance(spec, dict) and spec.get("url")
    )


def _stacks_using(module: dict, trunk: Path, stack_entries: "list[dict]") -> "list[str]":
    """Registered trunk stacks whose modules.repos pin this module's repo."""
    target = _norm_repo_url(module.get("repo", ""))
    users = []
    for stack in stack_entries:
        if _repo_slug(stack.get("repo", "")) != "castacks/AirStack":
            continue
        stack_dir = trunk / stack.get("path", f"stacks/{stack['name']}")
        if target and target in _stack_pins(stack_dir):
            users.append(stack["name"])
    return sorted(users)


# ------------------------------------------------------------- rendering


def _stack_link(stack: dict, trunk: Path, from_depth: int = 2) -> str:
    """Markdown link to a stack's README (relative for trunk stacks)."""
    name = stack["name"]
    rel_readme = Path(stack.get("path", f"stacks/{name}")) / "README.md"
    if (
        _repo_slug(stack.get("repo", "")) == "castacks/AirStack"
        and (trunk / rel_readme).is_file()
    ):
        return f"[{name}]({'../' * from_depth}{rel_readme.as_posix()})"
    return f"[{name}]({_norm_repo_url(stack.get('repo', ''))})"


def render_index(
    modules: "list[dict]",
    stacks: "list[dict]",
    trunk: Path,
) -> str:
    lines = [
        "# Module & Stack Catalog",
        "",
        GENERATED_MARKER,
        "",
        "The **marketplace catalog** of registered AirStack modules and stacks,",
        f"rendered from the",
        f"[airstack-modules-index]({REGISTRY_URL}) registry — one YAML entry per",
        "module or stack, rosdistro-style. Getting listed = a PR to the registry",
        f"(see the [registry README]({REGISTRY_URL}#how-to-register-a-module)).",
        "",
        "Compatibility shown here is the author-**DECLARED** semver range; the",
        f"**VERIFIED** matrix is CI-stamped into the registry's [compat/]({REGISTRY_URL}/tree/main/compat)",
        "directory and is never hand-edited.",
        "",
        "## Registered modules",
        "",
        "| Module | Description | Type | Maintainer | Declared compat | Links |",
        "|--------|-------------|------|------------|-----------------|-------|",
    ]
    for mod in modules:
        name = mod["name"]
        repo = _norm_repo_url(mod.get("repo", ""))
        users = _stacks_using(mod, trunk, stacks)
        links = [f"[repo]({repo})"]
        links += [
            f"[{u}](../../stacks/{u}/README.md)"
            for u in users
            if (trunk / "stacks" / u / "README.md").is_file()
        ]
        lines.append(
            "| [{n}]({n}.md) | {d} | `{t}` | {m} | `{c}` | {l} |".format(
                n=name,
                d=_md_cell(mod.get("description", "")),
                t=mod.get("type", "?"),
                m=_md_cell(mod.get("maintainer", "?")),
                c=_md_cell(mod.get("airstack_compat", "?")),
                l=" · ".join(links),
            )
        )
    lines += [
        "",
        "## Registered stacks",
        "",
        "A stack is a self-contained topology folder; its pinned `modules.repos`",
        "*is* a tested-together release set.",
        "The stacks below are the ones REGISTERED in the index; the site nav's",
        "**Modules → Reference Stacks** additionally lists every trunk stack",
        "(not every trunk stack is registered in the index).",
        "",
        "| Stack | Description | Declared compat | Wiring | Registry entry |",
        "|-------|-------------|-----------------|--------|----------------|",
    ]
    for stack in stacks:
        name = stack["name"]
        wiring_rel = Path(stack.get("path", f"stacks/{name}")) / "wiring.md"
        if (
            _repo_slug(stack.get("repo", "")) == "castacks/AirStack"
            and (trunk / wiring_rel).is_file()
        ):
            wiring = f"[wiring.md](../../{wiring_rel.as_posix()})"
        else:
            wiring = "*not committed yet*"
        lines.append(
            "| {s} | {d} | `{c}` | {w} | [{n}.yaml]({u}/blob/main/stacks/{n}.yaml) |".format(
                s=_stack_link(stack, trunk),
                d=_md_cell(stack.get("description", "")),
                c=_md_cell(stack.get("airstack_compat", "?")),
                w=wiring,
                n=name,
                u=REGISTRY_URL,
            )
        )
    lines += [
        "",
        "## See also",
        "",
        "- [Modular AirStack walkthrough](../getting_started/modular_airstack.md) — the",
        "  new-developer journey: reference stack → add a module → own stack → fleet",
        "- [AirStack Modules](../development/modules.md) — `airstack module` CLI, the",
        "  pinning rule, hooks, and the overlay",
        "- [AirStack Stacks](../development/stacks.md) — stack anatomy, `stack new|diff`,",
        "  wiring snapshots, `doctor`",
        "- [AirStack Fleets](../development/fleets.md) — fleet files composing stacks",
        "  into deployments",
        "- [Module CI](../development/module_ci.md) — the reusable system-test workflow",
        "  module repos call; how compat badges are earned",
        "- [Interface Conventions Spec](../robot/autonomy/interface_conventions.md) —",
        "  the canonical names/types/QoS modules default to",
        "",
    ]
    return "\n".join(lines)


def render_module_page(
    mod: dict,
    trunk: Path,
    stacks: "list[dict]",
    modules_dir: Path,
) -> str:
    name = mod["name"]
    repo = _norm_repo_url(mod.get("repo", ""))
    ref = str(mod.get("registered_ref", "main"))
    users = _stacks_using(mod, trunk, stacks)
    fetched = (modules_dir / name / "README.md").is_file()

    lines = [
        f"# {name}",
        "",
        GENERATED_MARKER,
        "",
        f"> {' '.join(str(mod.get('description', '')).split())}",
        "",
        "| | |",
        "|---|---|",
        f"| Repository | [{_repo_slug(repo)}]({repo}) |",
        f"| Type | `{mod.get('type', '?')}` |",
        f"| Maintainer | {_md_cell(mod.get('maintainer', '?'))} |",
        f"| License | {_md_cell(mod.get('license', '?'))} |",
        f"| Registered ref | [`{_short_ref(ref)}`]({repo}/tree/{ref}) |",
        f"| Declared compat | `{_md_cell(mod.get('airstack_compat', '?'))}` |",
        f"| Registry entry | [modules/{name}.yaml]({REGISTRY_URL}/blob/main/modules/{name}.yaml) |",
        "",
        "## Install",
        "",
        "From an AirStack checkout ([AirStack Modules guide](../development/modules.md)):",
        "",
        "```bash",
        f"airstack module add {repo} --version {ref}",
        "airstack up",
        "```",
        "",
        "`module add` pins the module in `modules.repos` and syncs it into the",
        "gitignored `modules/` overlay; `airstack up` automatically includes the",
        "generated compose override that mounts it into the containers.",
        "",
        "## Compatibility: declared vs verified",
        "",
        f"The range `{_md_cell(mod.get('airstack_compat', '?'))}` is **DECLARED** by the module author",
        "(copied from the module's `module.yaml`). The **VERIFIED** record — rows",
        "stamped exclusively by CI runs of the reusable",
        "[module-system-tests workflow](../development/module_ci.md) — lives in the",
        f"registry's [compat/ matrix]({REGISTRY_URL}/tree/main/compat)",
        f"([compat/{name}.yaml]({REGISTRY_URL}/blob/main/compat/{name}.yaml) once stamped).",
        "A compatibility claim that isn't CI-verified rots: trust the",
        "matrix, read the declaration as intent.",
        "",
        "## Documentation",
        "",
        f"- [Module README on GitHub @ `{_short_ref(ref)}`]({repo}/blob/{ref}/README.md)",
    ]
    if fetched:
        lines += [
            f"- A snapshot of the module repo was fetched into `modules/{name}/` when",
            "  this page was generated (docs deploy fetch step).",
        ]
    else:
        lines += [
            f"- *The module repo was not fetched when this page was generated — the*",
            "  *links above go to GitHub at the registered ref (failure isolation:*",
            "  *an unreachable module repo never fails the docs deploy).*",
        ]
    lines += [
        "",
        "## Registered stacks using this module",
        "",
    ]
    if users:
        lines += [
            f"- [{u}](../../stacks/{u}/README.md)"
            for u in users
            if (trunk / "stacks" / u / "README.md").is_file()
        ]
    else:
        lines.append(
            "- None yet. Any stack can pin it in its `modules.repos` "
            "([AirStack Stacks](../development/stacks.md))."
        )
    notes = str(mod.get("notes", "")).strip()
    if notes:
        lines += ["", "## Registry notes", ""]
        lines += [f"> {ln}".rstrip() for ln in " ".join(notes.split()).splitlines()]
    lines.append("")
    return "\n".join(lines)


# ------------------------------------------------------------------ main


def generate(index: Path, out: Path, trunk: Path, modules_dir: Path) -> "dict[str, str]":
    modules = _load_entries(index / "modules")
    stacks = _load_entries(index / "stacks")
    if not modules:
        _die(f"no module entries found under {index / 'modules'}")
    pages = {"index.md": render_index(modules, stacks, trunk)}
    for mod in modules:
        pages[f"{mod['name']}.md"] = render_module_page(mod, trunk, stacks, modules_dir)
    return pages


def write_pages(pages: "dict[str, str]", out: Path) -> None:
    out.mkdir(parents=True, exist_ok=True)
    for rel, content in sorted(pages.items()):
        (out / rel).write_text(content)


def check_pages(pages: "dict[str, str]", out: Path) -> int:
    """CI drift check: committed pages must match regeneration. 0 = clean."""
    drift = 0
    committed = {p.name for p in out.glob("*.md")} if out.is_dir() else set()
    for rel in sorted(set(pages) | committed):
        want = pages.get(rel)
        have_path = out / rel
        have = have_path.read_text() if have_path.is_file() else None
        if want == have:
            continue
        drift = 1
        if want is None:
            print(f"DRIFT: {have_path} is committed but no longer generated", file=sys.stderr)
        elif have is None:
            print(f"DRIFT: {have_path} is generated but not committed", file=sys.stderr)
        else:
            print(f"DRIFT: {have_path} differs from regeneration:", file=sys.stderr)
            diff = difflib.unified_diff(
                have.splitlines(), want.splitlines(),
                fromfile=f"committed/{rel}", tofile=f"generated/{rel}", lineterm="",
            )
            for line in list(diff)[:40]:
                print(f"  {line}", file=sys.stderr)
    if drift:
        print(
            "gen_docs_catalog --check: docs/modules/ pages are stale — rerun\n"
            "  python3 tools/gen_docs_catalog.py --index <registry-checkout>\n"
            "and commit the result.",
            file=sys.stderr,
        )
    return drift


def main(argv: "list[str] | None" = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument(
        "--index", required=True, type=Path,
        help="local checkout of castacks/airstack-modules-index",
    )
    parser.add_argument(
        "--out", type=Path, default=None,
        help="output directory (default: <trunk>/docs/modules)",
    )
    parser.add_argument(
        "--trunk", type=Path, default=TRUNK,
        help="AirStack checkout root (default: this script's repo)",
    )
    parser.add_argument(
        "--modules-dir", type=Path, default=None,
        help="dir of fetched module repos, modules/<name>/ "
             "(default: <trunk>/modules; absence per module => stub note)",
    )
    parser.add_argument(
        "--check", action="store_true",
        help="verify committed pages match regeneration; exit 1 on drift",
    )
    parser.add_argument(
        "--list-refs", action="store_true",
        help="print 'name<TAB>repo<TAB>registered_ref' per module and exit "
             "(used by the docs deploy workflows' fetch loop)",
    )
    args = parser.parse_args(argv)

    trunk = args.trunk.resolve()
    index = args.index.resolve()
    if not (index / "modules").is_dir():
        _die(f"{index} does not look like a registry checkout (no modules/ dir)")
    out = (args.out if args.out is not None else trunk / "docs" / "modules").resolve()
    modules_dir = (
        args.modules_dir if args.modules_dir is not None else trunk / "modules"
    ).resolve()

    if args.list_refs:
        for mod in _load_entries(index / "modules"):
            print(
                f"{mod['name']}\t{_norm_repo_url(mod.get('repo', ''))}"
                f"\t{mod.get('registered_ref', 'main')}"
            )
        return 0

    pages = generate(index, out, trunk, modules_dir)
    if args.check:
        return check_pages(pages, out)
    write_pages(pages, out)
    print(f"gen_docs_catalog: wrote {len(pages)} pages to {out}")
    return 0


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