#!/usr/bin/env python3
"""Enforce the database layer naming convention.

Rules checked:

  1. every public function's name starts with the name of the file it lives in
  2. the file's own name uses a prefix from the closed set, or is exempt
  3. iterate_ iff the function takes a caller-supplied callback; get_ iff not
  4. a .h exists beside every .c, and every .sql that is not schema or
     boilerplate is named after the .c that runs it
  5. every prepared statement is named after the function preparing it
  6. no prepared statement name is used in two files
  7. no numeric suffixes and no repeated library prefix
  8. no orphaned header: every .h in the API directory has an implementation

Run from the top of the source tree; exits non-zero on any violation.
"""
import re
import sys
import pathlib

# ---------------------------------------------------------------- CONFIG --
# (impl dir, header dir, symbol prefix, .c basename prefix)
LAYERS = [
    ("src/stasis", "src/include/anastasis/anastasis-database",
     "ANASTASIS_DB_", ""),
]

# Prefixes that describe what the caller gets.
PREFIXES = ("get_", "get_count_", "get_exists_", "iterate_",
            "insert_", "update_", "update_to_", "delete_", "do_")

# Not CRUD: transaction control, DDL, events, memory, session plumbing,
# cancellable handle APIs, and multi-function helper modules.  Keep this list
# short and justified -- every entry is a rule the checker stops enforcing.
EXEMPT = {
    # transaction control (declared in transaction.h, implemented in
    # anastasis-db_pg.c)
    "start", "commit", "rollback", "check_connection", "preflight",
    # DDL and maintenance
    "create_tables", "drop_tables", "gc", "gc_challenge_codes",
    # events (declared in event.h, implemented in anastasis-db_pg.c)
    "event_listen", "event_listen_cancel", "event_notify",
    # subsystem lifecycle
    "init", "init_admin", "fini",
}

# Headers that hold shared declarations rather than one public function.
HDR_EXEMPT = {"common", "event", "transaction"}

# Files that are not part of the API surface at all.  Listed by name on
# purpose: a "skip anything called test_*" rule would silently exempt any
# future API function whose name starts with test_, which is exactly how
# ANASTASIS_DB_test_auth_iban_payment() and
# ANASTASIS_DB_test_challenge_code_satisfied() escaped notice.
SKIP = {"anastasis-db_pg", "anastasis-dbinit", "test_anastasis_db"}

# SQL that belongs to the schema or to the amalgamation boilerplate rather
# than to one API function.
SQL_NOT_A_FUNCTION = {"versioning", "drop", "commit", "procedures-prelude"}
SQL_SCHEMA_RE = re.compile(r"^stasis-\d+$")
# ------------------------------------------------------------ END CONFIG --

PREP = re.compile(r'PREPARE\s*\(\s*"([A-Za-z0-9_]+)"', re.S)

errors = []


def check_layer(impl_dir, hdr_dir, prefix, fpfx):
    impl, hdr = pathlib.Path(impl_dir), pathlib.Path(hdr_dir)
    if not impl.is_dir():
        return
    decl = re.compile(r"^" + re.escape(prefix) + r"(\w+)\s*\((.*?)\)\s*;",
                      re.S | re.M)
    stmt_owner = {}
    stems = set()

    for c in sorted(impl.glob("*.c")):
        if c.stem in SKIP:
            continue
        if fpfx and not c.stem.startswith(fpfx):
            continue
        stem = c.stem[len(fpfx):] if fpfx else c.stem
        stems.add(stem)

        # 2. prefix from the closed set, or exempt
        if stem not in EXEMPT and not stem.startswith(PREFIXES):
            errors.append(f"{c}: '{stem}' uses no approved prefix")

        # 7. numeric suffix
        if re.search(r"\d$", stem):
            errors.append(f"{c}: '{stem}' ends in a digit")

        # 4. matching header
        h = hdr / (stem + ".h")
        if stem not in EXEMPT and not h.exists():
            errors.append(f"{c}: no matching header {h}")

        text = c.read_text(errors="replace")

        # 5. prepared statements named after the function
        for s in dict.fromkeys(PREP.findall(text)):
            if s != stem and not s.startswith(stem + "_"):
                errors.append(
                    f"{c}: prepared statement '{s}' is not named after "
                    f"'{stem}'")
            # 6. cross-file uniqueness
            if s in stmt_owner and stmt_owner[s] != str(c):
                errors.append(
                    f"{c}: prepared statement '{s}' also prepared in "
                    f"{stmt_owner[s]}")
            stmt_owner[s] = str(c)

        if not h.exists():
            continue
        htext = re.sub(r"/\*.*?\*/", "", h.read_text(errors="replace"),
                       flags=re.S)
        for name, params in decl.findall(htext):
            # 1. declared names belong to their file.  Exempt entries are the
            # documented multi-function modules, so this does not apply there.
            if (stem not in EXEMPT
                    and name != stem and not name.startswith(stem + "_")):
                errors.append(
                    f"{h}: declares '{name}', which does not belong to "
                    f"'{stem}'")
            # 3. iterate_ iff callback
            has_cb = bool(re.search(r"\w*(Callback|Iterator)\s+\w+", params))
            if name.startswith("iterate_") and not has_cb:
                errors.append(
                    f"{h}: '{name}' is iterate_ but takes no callback")
            if name.startswith("get_") and has_cb:
                errors.append(f"{h}: '{name}' takes a callback; use iterate_")

    # 4. an amalgamated .sql is named after the .c that runs it, so that a
    # renamed function cannot leave its stored procedure behind.
    for s in sorted(impl.glob("*.sql")):
        if s.stem in SQL_NOT_A_FUNCTION or SQL_SCHEMA_RE.match(s.stem):
            continue
        if s.stem not in stems:
            errors.append(f"{s}: no {impl}/{s.stem}.c runs this SQL")

    # 8. every header belongs to an implementation
    for h in sorted(hdr.glob("*.h")):
        if h.stem in HDR_EXEMPT:
            continue
        if not (impl / (fpfx + h.stem + ".c")).exists():
            errors.append(f"{h}: no implementation {impl / (fpfx + h.stem)}.c")

    # 7. repeated library prefix anywhere in the tree
    head = prefix.split("_")[0]
    for f in list(impl.glob("*.[ch]")) + list(hdr.glob("*.h")):
        if "unc-backup" in f.name:
            continue
        if re.search(r"\b" + re.escape(head) + r"_" + re.escape(prefix),
                     f.read_text(errors="replace")):
            errors.append(f"{f}: repeated '{prefix}' prefix")


for a in LAYERS:
    check_layer(*a)

for e in errors:
    print("ERROR:", e)
print(f"\ndb-naming: {len(errors)} violation(s)")
sys.exit(1 if errors else 0)
