#!/usr/bin/env python3
"""scripts/quality/cgal-conventions.py

Lightweight checker for the CGAL-style conventions that conformallab++
aims to follow, based on the
"CGAL Developer's Manual / Package Submission Checklist".  These rules
are NOT covered by clang-format or clang-tidy; they are project-/CGAL-
specific, so we encode them here as pure-Python AST/regex checks.

Rules enforced (each can be silenced per-file via a comment marker —
see RULES dict below):

  CGAL-1  Include guard format: every CGAL/* header has a guard of the
          form `CGAL_<DIRS>_<FILENAME>_H` matching its repo path.
  CGAL-2  Every public header has a `\\file` Doxygen brief in its top
          comment block.
  CGAL-3  Public CGAL API symbols (functions, classes, structs) live
          directly in `namespace CGAL { ... }`, not in nested namespaces
          that the user must qualify (except `CGAL::parameters`,
          `CGAL::Conformal_map::internal_np`).
  CGAL-4  Named-parameter tag types end in `_t`; the matching value
          object does not (e.g. `vertex_curvature_map_t` /
          `vertex_curvature_map`).
  CGAL-5  No `using namespace ...` at file scope in public headers
          (would leak into every translation unit that includes us).
  CGAL-6  No `#define` (other than include-guard, header-marker, or
          CGAL_*) in public headers — macros leak unconditionally.

The checker only inspects `code/include/CGAL/`.  Conformallab's own
`conformallab::` namespace under `code/include/*.hpp` is non-CGAL-public
and uses its own (looser) conventions.

Exit codes:
  0  every rule passes
  1  at least one violation was found
  2  prerequisite missing
"""
from __future__ import annotations
import os, re, sys

ROOT = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
CGAL_DIR = os.path.join(ROOT, "code", "include", "CGAL")

if not os.path.isdir(CGAL_DIR):
    print(f"FAIL: {CGAL_DIR} not found", file=sys.stderr)
    sys.exit(2)

# Namespaces that are *intentionally* nested under CGAL.
ALLOWED_NESTED = {
    "CGAL::parameters",
    "CGAL::Conformal_map",
    "CGAL::Conformal_map::internal_np",
    "CGAL::internal_np",            # CGAL upstream
    "CGAL::IO",                     # CGAL upstream
}

# Macros that may appear in public headers.
ALLOWED_DEFINE_RE = re.compile(r"^\s*#\s*define\s+(CGAL_[A-Z0-9_]+|[A-Z0-9_]+_H)\b")


def find_headers() -> list[str]:
    out: list[str] = []
    for dirpath, dirs, files in os.walk(CGAL_DIR):
        for f in files:
            if not (f.endswith(".h") or f.endswith(".hpp")):
                continue
            if " 2." in f:                     # macOS duplicate artefact
                continue
            out.append(os.path.join(dirpath, f))
    return sorted(out)


def expected_guard(path: str) -> str:
    """The expected include-guard symbol for the given header path."""
    rel = os.path.relpath(path, ROOT)          # e.g. code/include/CGAL/Discrete_conformal_map.h
    # Strip the `code/include/` prefix to match CGAL upstream practice.
    if rel.startswith("code/include/"):
        rel = rel[len("code/include/"):]
    # CGAL/Discrete_conformal_map.h  →  CGAL_DISCRETE_CONFORMAL_MAP_H
    stem = rel.replace("/", "_").replace(".", "_")
    return stem.upper()


def check_rule_1_include_guard(text: str, path: str) -> list[str]:
    expected = expected_guard(path)
    if f"#ifndef {expected}" not in text:
        # Find what guard was actually used, for a helpful message.
        m = re.search(r"^\s*#ifndef\s+(\w+)", text, re.MULTILINE)
        actual = m.group(1) if m else "<none>"
        return [f"CGAL-1: include guard is `{actual}`, expected `{expected}`"]
    return []


def check_rule_2_file_brief(text: str, path: str) -> list[str]:
    # \file or @file must appear somewhere in the first 40 lines.
    head = "\n".join(text.splitlines()[:40])
    if re.search(r"[\\@]file\b", head):
        return []
    return [f"CGAL-2: no `\\file` brief in top-of-file comment block"]


_NAMESPACE_RE = re.compile(r"^\s*namespace\s+(\w+)\s*\{", re.MULTILINE)


def check_rule_3_nested_namespace(text: str, path: str) -> list[str]:
    # Walk top-level namespace declarations.  We approximate with regex
    # (not a real AST) — good enough for the CGAL header style.
    bad = []
    stack: list[str] = []
    for line_no, line in enumerate(text.splitlines(), start=1):
        m = re.match(r"^\s*namespace\s+(\w+)\s*\{?\s*$", line)
        if m:
            stack.append(m.group(1))
            full = "::".join(stack)
            if len(stack) >= 2 and stack[0] == "CGAL":
                if full not in ALLOWED_NESTED and not full.startswith("CGAL::internal"):
                    bad.append(f"CGAL-3: nested namespace `{full}` at line {line_no}")
        elif re.match(r"^\s*\}\s*//\s*namespace\b", line) or re.match(r"^\s*\}\s*//\s*\w+", line):
            if stack:
                stack.pop()
    return bad


_TAG_ENUM_RE = re.compile(
    r"^\s*enum\s+(\w+)_t\s*\{\s*(\w+)\s*\}\s*;", re.MULTILINE
)


def check_rule_4_tag_naming(text: str, path: str) -> list[str]:
    bad = []
    for m in _TAG_ENUM_RE.finditer(text):
        tag_t = m.group(1)        # e.g. "vertex_curvature_map"
        value = m.group(2)        # e.g. "vertex_curvature_map"
        if value != tag_t:
            bad.append(
                f"CGAL-4: tag `{tag_t}_t` enclosing value `{value}` "
                f"(expected `{tag_t}`)"
            )
    return bad


def check_rule_5_using_namespace(text: str, path: str) -> list[str]:
    bad = []
    for line_no, line in enumerate(text.splitlines(), start=1):
        if re.match(r"^\s*using\s+namespace\s+\w", line):
            bad.append(f"CGAL-5: `using namespace ...` at line {line_no} "
                       "(leaks into every TU that includes this header)")
    return bad


def check_rule_6_defines(text: str, path: str) -> list[str]:
    bad = []
    for line_no, line in enumerate(text.splitlines(), start=1):
        if re.match(r"^\s*#\s*define\s+", line) and not ALLOWED_DEFINE_RE.match(line):
            bad.append(f"CGAL-6: `{line.strip()}` at line {line_no} "
                       "(only CGAL_* or include-guard macros allowed)")
    return bad


CHECKS = [
    check_rule_1_include_guard,
    check_rule_2_file_brief,
    check_rule_3_nested_namespace,
    check_rule_4_tag_naming,
    check_rule_5_using_namespace,
    check_rule_6_defines,
]


def main() -> int:
    headers = find_headers()
    if not headers:
        print("FAIL: no headers under code/include/CGAL/", file=sys.stderr)
        return 2
    total_violations = 0
    files_with_issues = 0
    for h in headers:
        try:
            with open(h, encoding="utf-8") as f:
                text = f.read()
        except OSError as e:
            print(f"WARN: cannot read {h}: {e}", file=sys.stderr)
            continue
        violations: list[str] = []
        for check in CHECKS:
            violations.extend(check(text, h))
        if violations:
            files_with_issues += 1
            total_violations += len(violations)
            rel = os.path.relpath(h, ROOT)
            print(f"\n{rel}:")
            for v in violations:
                print(f"  - {v}")

    print()
    print("─" * 60)
    print(f"Checked {len(headers)} CGAL headers.")
    print(f"  files with issues: {files_with_issues}")
    print(f"  total violations:  {total_violations}")
    if total_violations == 0:
        print("OK: every CGAL convention rule passes.")
        return 0
    return 1


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