#!/usr/bin/env python3
# @ref https://github.com/ccheever/llp/blob/main/llp/0000-linked-literate-programming.explainer.md#6-validation-ref-check — implements the checker specified there
"""ref-check — deterministic validator for LLP corpora and @ref annotations.

Usage:
    ref-check [--root DIR] [--verbose]

Checks (broken = exit 1):
  * @ref targets resolve: LLP numbers, anchors, repo paths. URLs are
    shape-validated only (never fetched); shorthands are listed as
    unchecked unless the project defines a mapping.
  * LLP metadata headers parse; filename type matches **Type:**;
    LLP numbers are unique across the tree (tombstones included).
  * No [inferred] claim survives in an Accepted/Active document.

Judgment calls (stale glosses, drift, orphaned annotations) are
deliberately not gated — that is interactive work for llp-maintain.
Directories containing their own llp/ are separate corpus roots and are
skipped; check them with --root. llp/tombstones/ and llp/reviews/ are
exempt from ref validation.
"""

import argparse
import os
import re
import sys

SKIP_DIRS = {".git", ".hg", "node_modules", "__pycache__", ".venv", "venv",
             "dist", "build", "target", "coverage", ".cache", ".next",
             ".pytest_cache", ".turbo", ".idea", ".vscode"}
# `ref-check` is a source/document validator, not a generic text crawler.
# Keeping the scan surface explicit prevents machine-local build products and
# source maps from changing counts or inventing refs while retaining every
# source/config format used by this repository.
SCAN_EXTENSIONS = {
    ".c", ".cc", ".contract", ".cpp", ".css", ".go", ".h", ".hpp", ".html",
    ".java", ".js", ".json", ".jsonc", ".jsonl", ".kt", ".md", ".mjs", ".mts",
    ".py", ".rb", ".rs", ".sh", ".sql", ".swift", ".toml", ".ts", ".tsx",
    ".txt", ".webmanifest", ".yaml", ".yml",
}
SCAN_BASENAMES = {
    ".gitattributes", ".gitignore", ".gitmodules", "Dockerfile", "Makefile",
}
SCAN_EXTENSIONLESS_DIRS = {"scripts", ".githooks"}
REQUIRED_FIELDS = ("Type", "Status", "Systems", "Author", "Date")
GATED_STATUSES = {"accepted", "active"}
MAX_BYTES = 2_000_000

# Built by concatenation so this file doesn't match its own scan patterns.
REF_MARK = "@" + "ref"
CODE_REF_RE = re.compile(re.escape(REF_MARK) + r"\s+(.+)")
MD_REF_RE = re.compile(r"<!--\s*" + re.escape(REF_MARK) + r"\s+(.+?)-->", re.DOTALL)
LLP_TARGET_RE = re.compile(r"^LLP\s+(\d{1,4})(?:#(\S+))?$")
SHORTHAND_RE = re.compile(r"^[A-Z][A-Z0-9_]*(?:#\S+)?$")
URL_RE = re.compile(r"^https?://[^\s/]+\.[^\s/]+\S*$")
HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
LLP_FILE_RE = re.compile(r"^(\d{4})-[a-z0-9-]+\.([a-z0-9]+)\.md$")
FIELD_RE = re.compile(r"^\*\*(\w+):\*\*\s*(.+)$")
INFERRED = "[" + "inferred]"


def slugify(text):
    """GitHub-style heading slug: lowercase; spaces -> hyphens; keep
    alphanumerics, hyphens, underscores; drop everything else."""
    text = text.replace("`", "")
    out = []
    for ch in text.strip().lower():
        if ch.isalnum() or ch in "-_":
            out.append(ch)
        elif ch in " \t":
            out.append("-")
    return "".join(out)


def read_text(path):
    try:
        if os.path.getsize(path) > MAX_BYTES:
            return None
        with open(path, encoding="utf-8") as f:
            return f.read()
    except (UnicodeDecodeError, OSError):
        return None


def is_scan_candidate(relpath):
    """Whether a repository-relative path is source/config/document text."""
    normalized = relpath.replace(os.sep, "/")
    name = os.path.basename(normalized)
    if name in SCAN_BASENAMES:
        return True
    _, extension = os.path.splitext(name)
    if extension.lower() in SCAN_EXTENSIONS:
        return True
    return not extension and normalized.split("/", 1)[0] in SCAN_EXTENSIONLESS_DIRS


def md_headings(text):
    """Heading texts outside fenced code blocks."""
    headings, fenced = [], False
    for line in text.splitlines():
        if line.lstrip().startswith("```"):
            fenced = not fenced
            continue
        if fenced:
            continue
        m = HEADING_RE.match(line)
        if m:
            headings.append(m.group(2))
    return headings


def anchor_resolves(anchor, headings):
    anchor = anchor.lstrip("#")
    slugs = {slugify(h) for h in headings}
    if anchor.lower() in slugs:
        return True
    if re.fullmatch(r"\d+(\.\d+)*", anchor):  # numbered-section form: #3, #3.2
        return any(re.match(rf"^{re.escape(anchor)}([.)\s]|$)", h) for h in headings)
    return False


class Report:
    def __init__(self):
        self.errors, self.warnings, self.infos = [], [], []

    def error(self, msg):
        self.errors.append(msg)

    def info(self, msg):
        self.infos.append(msg)


def parse_target(rest):
    """Split an annotation body into (kind, target, anchor)."""
    rest = rest.strip()
    rest = re.split(r"\s+(?:[—–]|--|-)(?:\s+|$)", rest)[0].strip()  # drop gloss
    rest = re.sub(r"\s*\[[a-z-]+\]\s*$", "", rest)        # drop [relation]
    m = LLP_TARGET_RE.match(rest)
    if m:
        return "llp", m.group(1), m.group(2)
    token = rest.split()[0] if rest.split() else ""
    if token.startswith(("http://", "https://")):
        return "url", token, None
    if SHORTHAND_RE.match(token):
        return "shorthand", token, None
    path, _, anchor = token.partition("#")
    return "path", path, anchor or None


def collect_refs(path, text):
    refs = []  # (lineno, rest)
    if path.endswith(".md"):
        for m in MD_REF_RE.finditer(text):
            lineno = text.count("\n", 0, m.start()) + 1
            refs.append((lineno, m.group(1)))
    else:
        for i, line in enumerate(text.splitlines(), 1):
            m = CODE_REF_RE.search(line)
            if m:
                refs.append((i, m.group(1)))
    return refs


def check(root):
    rep = Report()
    llp_dir = os.path.join(root, "llp")

    # --- corpus: collect LLP docs, check metadata ---
    docs = {}  # number -> relpath
    headings = {}  # number -> [heading, ...]
    if os.path.isdir(llp_dir):
        for dirpath, dirnames, filenames in os.walk(llp_dir):
            dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and d != "reviews"]
            for name in sorted(filenames):
                m = LLP_FILE_RE.match(name)
                if not m:
                    continue
                num, ftype = m.group(1), m.group(2)
                rel = os.path.relpath(os.path.join(dirpath, name), root)
                text = read_text(os.path.join(dirpath, name))
                if text is None:
                    continue
                if num in docs:
                    rep.error(f"{rel}: duplicate LLP number {num} (also {docs[num]})")
                else:
                    docs[num] = rel
                    headings[num] = md_headings(text)
                fields = {}
                for line in text.splitlines()[1:40]:
                    fm = FIELD_RE.match(line.strip())
                    if fm:
                        fields.setdefault(fm.group(1), fm.group(2).strip())
                for req in REQUIRED_FIELDS:
                    if req not in fields:
                        rep.error(f"{rel}: missing required metadata field **{req}:**")
                doc_type = fields.get("Type", "").split()[0].lower() if fields.get("Type") else ""
                if doc_type and doc_type != ftype:
                    rep.error(f"{rel}: filename type '.{ftype}.md' != **Type:** {fields['Type']}")
                status = fields.get("Status", "").split()[0].lower()
                if status in GATED_STATUSES:
                    bare = text.count(INFERRED) - text.count("`" + INFERRED)
                    if bare > 0:
                        rep.error(f"{rel}: {INFERRED} claim in {fields.get('Status')} document "
                                  f"(ratify to [confirmed] or remove before promotion)")

    # --- refs: scan the tree ---
    n_files = n_refs = 0
    exempt = (os.path.join("llp", "tombstones"), os.path.join("llp", "reviews"))
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        if dirpath != root and "llp" in dirnames:
            # a nested llp/ marks a separate corpus root (fixture, vendored
            # project): its refs resolve against its own tree, not this one
            dirnames[:] = []
            continue
        for name in sorted(filenames):
            full = os.path.join(dirpath, name)
            rel = os.path.relpath(full, root)
            if rel.startswith(exempt) or os.path.basename(rel) == "ref-check":
                continue
            if not is_scan_candidate(rel):
                continue
            text = read_text(full)
            if text is None:
                continue
            n_files += 1
            for lineno, rest in collect_refs(rel, text):
                n_refs += 1
                kind, target, anchor = parse_target(rest)
                where = f"{rel}:{lineno}"
                if kind == "llp":
                    num = target.zfill(4)
                    if num not in docs:
                        rep.error(f"{where}: broken ref: LLP {num} not found under llp/")
                    elif anchor and not anchor_resolves(anchor, headings[num]):
                        rep.error(f"{where}: broken ref: no anchor #{anchor} in {docs[num]}")
                elif kind == "url":
                    if not URL_RE.match(target):
                        rep.error(f"{where}: malformed URL target: {target}")
                elif kind == "shorthand":
                    rep.info(f"{where}: unchecked shorthand: {target} (no mapping defined)")
                else:  # path
                    tpath = os.path.join(root, target)
                    if not os.path.exists(tpath):
                        rep.error(f"{where}: broken ref: path not found: {target}")
                    elif anchor:
                        if target.endswith(".md"):
                            ttext = read_text(tpath) or ""
                            if not anchor_resolves(anchor, md_headings(ttext)):
                                rep.error(f"{where}: broken ref: no anchor #{anchor} in {target}")
                        else:
                            rep.info(f"{where}: anchor #{anchor} unchecked (non-markdown target)")
    return rep, n_files, n_refs, len(docs)


def main():
    ap = argparse.ArgumentParser(prog="ref-check", description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--root", default=".", help="project root to check (default: .)")
    ap.add_argument("--verbose", action="store_true", help="also print informational notes")
    args = ap.parse_args()

    rep, n_files, n_refs, n_docs = check(os.path.abspath(args.root))
    for e in rep.errors:
        print(f"ERROR {e}")
    if args.verbose:
        for i in rep.infos:
            print(f"INFO  {i}")
    status = "FAIL" if rep.errors else "ok"
    print(f"ref-check: {status} — {n_docs} LLP docs, {n_refs} refs in {n_files} files, "
          f"{len(rep.errors)} errors, {len(rep.infos)} unchecked")
    sys.exit(1 if rep.errors else 0)


if __name__ == "__main__":
    main()
