#!/bin/bash
# scripts/doxygen-coverage.sh
#
# Measure Doxygen documentation coverage of the public C++ API by parsing
# the Doxygen XML output.  Reports:
#   * total documentable members (functions, classes, structs, enums,
#     typedefs, variables) in code/include/**
#   * how many have a non-empty briefdescription/detaileddescription
#   * coverage % and list of undocumented members
#
# Prerequisite: doxygen must have been run with GENERATE_XML=YES (which
# the project's Doxyfile sets).  This script invokes it if XML is missing.
#
# Usage:
#   bash scripts/doxygen-coverage.sh               # short summary
#   bash scripts/doxygen-coverage.sh --list-undoc  # list undocumented members
#   bash scripts/doxygen-coverage.sh --threshold 95  # fail if coverage < 95 %
#
# Exit codes:
#   0  coverage ≥ threshold (default 0 — informational only)
#   1  coverage <  threshold
#   2  XML output missing / could not be parsed

set -eu

XML_DIR="doc/doxygen/xml"
THRESHOLD=0
LIST_UNDOC=0

INCLUDE_DETAIL=0

while [ $# -gt 0 ]; do
  case "$1" in
    --threshold) THRESHOLD="$2"; shift 2 ;;
    --list-undoc) LIST_UNDOC=1; shift ;;
    --include-detail) INCLUDE_DETAIL=1; shift ;;
    *) echo "Unknown arg: $1" >&2; exit 2 ;;
  esac
done

if [ ! -d "$XML_DIR" ]; then
  echo "XML output missing — running doxygen..."
  doxygen Doxyfile >/dev/null 2>&1
fi

if [ ! -d "$XML_DIR" ]; then
  echo "ERROR: $XML_DIR still missing after doxygen run" >&2
  exit 2
fi

python3 - "$XML_DIR" "$LIST_UNDOC" "$THRESHOLD" "$INCLUDE_DETAIL" <<'PYEOF'
import sys, os, glob, xml.etree.ElementTree as ET

xml_dir, list_undoc, threshold, include_detail = \
    sys.argv[1], int(sys.argv[2]), float(sys.argv[3]), int(sys.argv[4])

# Implementation-detail namespaces — not part of the public API surface.
# Skipped by default; pass --include-detail to count them too.
DETAIL_NAMES = ("::detail::", "::detail_xml::", "::cp_detail::", "::id_detail::",
                "::detail$", "::detail_xml$", "::cp_detail$", "::id_detail$")

def is_detail(qualified_name: str) -> bool:
    if include_detail:
        return False
    return any(qualified_name.find(d.rstrip("$")) >= 0 for d in DETAIL_NAMES)

# Restrict to compounds whose location is under code/include/ (the
# public API).  XML output also includes README.md and CLAUDE.md as
# "file" kind compounds, which we want to skip.
PUBLIC_PREFIX = os.path.abspath("code/include") + os.sep

KINDS = {"function", "class", "struct", "enum", "typedef", "variable", "namespace"}

total = 0
documented = 0
undoc = []

for path in sorted(glob.glob(os.path.join(xml_dir, "*.xml"))):
    if os.path.basename(path) in {"index.xml", "Doxyfile.xml", "indexpage.xml"}:
        continue
    if os.path.basename(path).startswith(("namespacestd", "md_")):
        continue
    try:
        tree = ET.parse(path)
    except ET.ParseError:
        continue
    for cd in tree.iter("compounddef"):
        kind = cd.attrib.get("kind", "")
        # only count compounds living in our public include tree
        loc = cd.find("location")
        if loc is None:
            continue
        file_attr = loc.attrib.get("file", "")
        if not file_attr.startswith(PUBLIC_PREFIX) and \
           not file_attr.startswith("code/include/"):
            continue

        # The compound itself
        if kind in {"class", "struct", "namespace"}:
            cname = cd.findtext("compoundname", "?")
            if not is_detail(cname):
                total += 1
                brief = cd.find("briefdescription")
                detail = cd.find("detaileddescription")
                has_doc = (brief is not None and len("".join(brief.itertext()).strip()) > 0) or \
                          (detail is not None and len("".join(detail.itertext()).strip()) > 0)
                if has_doc:
                    documented += 1
                else:
                    undoc.append(f"{kind:9s}  {cname}  ({file_attr}:{loc.attrib.get('line','?')})")

        # Members inside the compound
        for memberdef in cd.iter("memberdef"):
            mkind = memberdef.attrib.get("kind", "")
            if mkind not in KINDS:
                continue
            prot = memberdef.attrib.get("prot", "public")
            if prot != "public":
                continue
            name = memberdef.findtext("name", "?")
            qual = memberdef.findtext("qualifiedname", name)
            if is_detail(qual):
                continue
            total += 1
            brief = memberdef.find("briefdescription")
            detail = memberdef.find("detaileddescription")
            has_doc = (brief is not None and len("".join(brief.itertext()).strip()) > 0) or \
                      (detail is not None and len("".join(detail.itertext()).strip()) > 0)
            if has_doc:
                documented += 1
            else:
                mloc = memberdef.find("location")
                fl = mloc.attrib.get("file", "?") if mloc is not None else "?"
                ln = mloc.attrib.get("line", "?") if mloc is not None else "?"
                undoc.append(f"{mkind:9s}  {qual}  ({fl}:{ln})")

if total == 0:
    print("ERROR: no public members found — check that GENERATE_XML=YES and EXTRACT_ALL=YES")
    sys.exit(2)

pct = 100.0 * documented / total
print(f"Doxygen coverage (public symbols under code/include/):")
print(f"  documented:   {documented}")
print(f"  total:        {total}")
print(f"  coverage:     {pct:.1f}%")
print(f"  undocumented: {total - documented}")

if list_undoc:
    print()
    print("Undocumented symbols:")
    for s in undoc:
        print(f"  {s}")

if pct < threshold:
    print(f"\nFAIL: coverage {pct:.1f}% < threshold {threshold}%", file=sys.stderr)
    sys.exit(1)

sys.exit(0)
PYEOF
