#!/usr/bin/env python3
"""scripts/gen-headers-md.py

Auto-generate `doc/api/headers.md` from the Doxygen XML index.  For
each public header under `code/include/`, the script extracts:

  * the header's `@file` brief description (if present), else falls
    back to the first leading `//` comment block in the source file;
  * the names of all public classes, structs, free functions, enums
    and typedefs declared at file scope (one-line list).

Headers are grouped by their directory:

  * `code/include/CGAL/`        →  "CGAL public API"
  * `code/include/CGAL/.../`    →  nested CGAL subgroups (internal_np, …)
  * `code/include/*.hpp`        →  "Core (conformallab namespace)"

The file is auto-generated; do NOT hand-edit.  Regenerate via:

    bash scripts/regen-docs.sh          # convenience wrapper
    # or:
    doxygen Doxyfile && python3 scripts/gen-headers-md.py

Prerequisite: `GENERATE_XML = YES` in the Doxyfile (already set).
"""

from __future__ import annotations
import os, sys, glob, xml.etree.ElementTree as ET
from collections import defaultdict

REPO_ROOT = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
XML_DIR   = os.path.join(REPO_ROOT, "doc", "doxygen", "xml")
OUT_PATH  = os.path.join(REPO_ROOT, "doc", "api", "headers.md")
PUBLIC_ABS = os.path.join(REPO_ROOT, "code", "include") + os.sep
PUBLIC_REL = "code/include/"


def is_public(path: str) -> bool:
    return path.startswith(PUBLIC_ABS) or path.startswith(PUBLIC_REL)


def relpath(path: str) -> str:
    if path.startswith(PUBLIC_ABS):
        return path[len(PUBLIC_ABS):]
    if path.startswith(PUBLIC_REL):
        return path[len(PUBLIC_REL):]
    return path


def text_of(elem: ET.Element | None) -> str:
    """Concatenate all text under elem, trimming whitespace."""
    if elem is None:
        return ""
    return " ".join("".join(elem.itertext()).split()).strip()


def first_sentence(text: str, max_chars: int = 240) -> str:
    """Return the first sentence (or first `max_chars` chars) of text.
    Heuristic: split at the first ". " (period + space) that follows
    at least 30 characters, else hard-truncate."""
    text = text.strip()
    if not text:
        return ""
    # Strip Doxygen tag-class artefacts that bleed into briefs
    text = text.replace("CGAL::Discrete_conformal_map", "")  # spurious self-reference
    for cut in [". ", ".\n", "; "]:
        i = text.find(cut, 30)
        if 0 < i < max_chars:
            return text[: i + 1].strip()
    if len(text) > max_chars:
        return text[: max_chars - 1].rstrip() + "…"
    return text


def first_leading_comment(path: str) -> str:
    """Fallback: extract the first contiguous //-comment block at top of file."""
    try:
        with open(path, encoding="utf-8") as f:
            lines = []
            started = False
            for ln in f:
                s = ln.strip()
                if s.startswith("//"):
                    lines.append(s.lstrip("/").strip())
                    started = True
                elif started:
                    break
                elif not s or s.startswith("#pragma"):
                    continue
                else:
                    break
            return " ".join(l for l in lines if l)
    except OSError:
        return ""


def scan_xml() -> dict[str, dict]:
    """Walk all compounddef kind='file' XMLs and return a dict
    keyed by absolute path → {'brief': str, 'symbols': [list]}.
    """
    out: dict[str, dict] = {}
    for xml in sorted(glob.glob(os.path.join(XML_DIR, "*.xml"))):
        bn = os.path.basename(xml)
        if bn in {"index.xml", "Doxyfile.xml", "indexpage.xml"}:
            continue
        try:
            tree = ET.parse(xml)
        except ET.ParseError:
            continue
        for cd in tree.iter("compounddef"):
            if cd.attrib.get("kind") != "file":
                continue
            loc = cd.find("location")
            if loc is None:
                continue
            path = loc.attrib.get("file", "")
            if not is_public(path):
                continue
            # Normalise to absolute for downstream file IO
            if path.startswith(PUBLIC_REL):
                path = os.path.join(REPO_ROOT, path)
            ext = os.path.splitext(path)[1]
            if ext not in {".h", ".hpp"}:
                continue
            brief = text_of(cd.find("briefdescription"))
            if not brief:
                brief = text_of(cd.find("detaileddescription"))
            if not brief:
                brief = first_leading_comment(path)
            brief = first_sentence(brief)

            symbols: list[str] = []
            # Free symbols defined directly in the file
            for sect in cd.iter("sectiondef"):
                for m in sect.iter("memberdef"):
                    mk = m.attrib.get("kind", "")
                    if mk not in {"function", "typedef", "enum", "variable"}:
                        continue
                    if m.attrib.get("prot", "public") != "public":
                        continue
                    name = m.findtext("name", "?")
                    qual = m.findtext("qualifiedname", name)
                    if "::detail" in qual or "::cp_detail" in qual or \
                       "::id_detail" in qual or "::detail_xml" in qual:
                        continue
                    if mk == "function":
                        symbols.append(f"`{name}()`")
                    elif mk == "typedef":
                        symbols.append(f"`{name}`")
                    elif mk == "enum":
                        symbols.append(f"`enum {name}`")
                    else:
                        symbols.append(f"`{name}`")
            # Classes/structs declared in the file (skip template
            # specialisations whose name contains angle brackets — those
            # appear as duplicates of the primary template).
            for cref in cd.iter("innerclass"):
                cname = cref.text or ""
                if "detail" in cname or "<" in cname:
                    continue
                short = cname.rsplit("::", 1)[-1]
                symbols.append(f"`{short}`")
            # De-duplicate, preserve order
            seen = set()
            uniq = []
            for s in symbols:
                if s in seen:
                    continue
                seen.add(s); uniq.append(s)
            out[path] = {"brief": brief, "symbols": uniq}
    return out


def group_key(path: str) -> tuple[str, int]:
    rel = relpath(path)
    parts = rel.split(os.sep)
    if parts[0] == "CGAL":
        if len(parts) == 2:
            return ("CGAL public API (`<CGAL/...>`)", 1)
        return (f"CGAL internals (`<CGAL/{parts[1]}/...>`)", 2)
    return ("Core (`conformallab` namespace, `<...>`)", 3)


def render(headers: dict[str, dict]) -> str:
    groups: dict[tuple[str, int], list[tuple[str, dict]]] = defaultdict(list)
    for path, info in headers.items():
        groups[group_key(path)].append((path, info))

    lines: list[str] = []
    lines.append("<!-- AUTO-GENERATED by scripts/gen-headers-md.py — do not edit by hand. -->")
    lines.append("<!-- Source of truth: the `\\file` / leading-comment briefs in code/include/. -->")
    lines.append("")
    lines.append("# Public Headers (`code/include/`)")
    lines.append("")
    lines.append("All algorithms are header-only.  Include the headers you need")
    lines.append("directly — there is no compiled library to link against (only")
    lines.append("GTest for tests and CGAL/Eigen for the CGAL-dependent headers).")
    lines.append("")
    lines.append("This page is **regenerated** from Doxygen XML on every push to")
    lines.append("`main` that touches a public header (see")
    lines.append("`.gitea/workflows/doxygen-pages.yml` and `scripts/gen-headers-md.py`).")
    lines.append("To improve a description, edit the `\\file` brief at the top of")
    lines.append("the corresponding header, then re-run `bash scripts/regen-docs.sh`.")
    lines.append("")

    for key in sorted(groups.keys(), key=lambda k: (k[1], k[0])):
        title, _ = key
        lines.append(f"## {title}")
        lines.append("")
        lines.append("| Header | Brief | Public symbols |")
        lines.append("|---|---|---|")
        for path, info in sorted(groups[key], key=lambda x: x[0]):
            rel = relpath(path).replace(os.sep, "/")
            brief = info["brief"] or "_(undocumented — add a `\\file` brief at the top of the header)_"
            # Defensive: collapse pipe/newline chars that would break the table
            brief = brief.replace("|", "\\|").replace("\n", " ").strip()
            syms  = ", ".join(info["symbols"][:10]) or "_(no public symbols)_"
            if len(info["symbols"]) > 10:
                syms += f", … (+{len(info['symbols']) - 10} more)"
            lines.append(f"| `{rel}` | {brief} | {syms} |")
        lines.append("")

    return "\n".join(lines) + "\n"


def main() -> int:
    if not os.path.isdir(XML_DIR):
        print(f"ERROR: {XML_DIR} missing — run `doxygen Doxyfile` first", file=sys.stderr)
        return 2
    headers = scan_xml()
    if not headers:
        print("ERROR: no public headers found in XML output", file=sys.stderr)
        return 2
    text = render(headers)
    with open(OUT_PATH, "w", encoding="utf-8") as f:
        f.write(text)
    print(f"Wrote {OUT_PATH} ({len(headers)} headers, {sum(len(h['symbols']) for h in headers.values())} symbols)")
    return 0


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