#!/usr/bin/env python3
# $Id: checkxref 336 2026-09-05 13:20:12Z peter $
#
# checkxref --- cross-reference defect detector for BSPPFit documents.
#
# Copyright (C) 2026 Peter Felecan
# Assisted by Claude Sonnet 5 (Anthropic).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Per DevelopmentStandard.org §Cross-Referencing Defined Terms (rule 5),
# this script enumerates body occurrences of glossary-anchored terms
# that are not preceded by an Org link to the term's canonical anchor.
#
# Usage:
#     checkxref [--brief] [DOC_DIR]
#
# DOC_DIR defaults to the current working directory.
# --brief produces a one-line-per-defect output suitable for grep.
# Without --brief, each defect prints with surrounding context.
#
# Exit codes:
#     0  no defects found
#     1  defects found (printed to stdout)
#     2  invocation error (e.g., DOC_DIR not found)
#
# The script enforces the every-occurrence rule with three documented
# exceptions:
#     (a) the definition itself (the line that contains <<term-X>>);
#     (b) successive uses within the same paragraph (paragraphs
#         separated by blank lines; once a term is linked in a
#         paragraph, subsequent uses in the same paragraph are not
#         flagged);
#     (c) tables of the term itself (rows whose subject IS the
#         term; the bilingual section is the canonical case --- the
#         heuristic skips table rows whose first cell already links
#         to the term anchor).
#
# What the script does NOT detect:
#     - Plurals and inflections beyond a simple +s suffix.
#     - Compound terms where the canonical form is a single word but
#       the body uses a multi-word variant.
#     - Case variations beyond the first-letter capitalisation that
#       glossary headings use.
# These limitations are documented; manual review of the defect
# report is expected.

import argparse
import os
import re
import sys
from pathlib import Path


# ---------------------------------------------------------------------
# Grammatical-mass terms: terms that function as common nouns of the
# domain and saturate the prose if linked at every occurrence. Per
# DevelopmentStandard.org §Cross-Referencing Defined Terms exception
# (d): grammatical-mass terms link at first occurrence per top-level
# section, not every occurrence. Specific-designator terms (BSPP,
# BSPT, NPT, O-ring etc.) continue to follow the every-occurrence
# rule.
#
# Membership in this list is a per-term decision documented in the
# journal entry that introduces the change. Adding a term here is not
# an admission that the rule has failed --- it is a recognition that
# the term's role in the document is grammatical (a common noun used
# throughout) rather than referential (a specific designator).
# ---------------------------------------------------------------------
GRAMMATICAL_MASS_TERMS = frozenset({
    "term-fitting",       # "fittings" appears in nearly every sentence
    "term-port",          # generic anatomical part of every fitting
    "term-male",          # gender, common adjective
    "term-female",        # gender, common adjective
    "term-cross",         # collides with "cross-reference",
                          # "cross-section" etc.; rarely the fitting
    "term-tap",           # ambiguous with verb sense
    "term-pitch",         # ambiguous with non-thread senses
    "term-bore",          # ambiguous with verb senses
})


# ---------------------------------------------------------------------
# Anchor and term enumeration.
# ---------------------------------------------------------------------

ANCHOR_RE = re.compile(r"<<(term-[a-z0-9-]+)>>")

# A glossary entry line has the form:
#     <indent>- *Term Name* :: <<term-canonical>> ...definition...
GLOSSARY_LINE_RE = re.compile(
    r"^\s*- \*([^*\n]+?)\* ::\s*<<(term-[a-z0-9-]+)>>"
)


def enumerate_anchors(doc_dir):
    """Walk doc_dir for .org files and return dict anchor -> term-name.

    The term-name is taken from the glossary entry's bold-italic
    heading, stripped of trailing parentheticals (so "Bushing
    (reducing bush)" becomes "Bushing" for body matching, while the
    anchor stays term-bushing).
    """
    anchor_to_term = {}
    anchor_to_location = {}  # anchor -> (file, lineno) of its definition

    for orgfile in sorted(doc_dir.rglob("*.org")):
        with open(orgfile, "r", encoding="utf-8") as f:
            for lineno, line in enumerate(f, 1):
                m = GLOSSARY_LINE_RE.match(line)
                if not m:
                    continue
                term_label, anchor = m.group(1), m.group(2)
                # Strip trailing parenthetical for body-matching.
                term = re.sub(r"\s*\([^)]*\)\s*$", "", term_label).strip()
                if anchor not in anchor_to_term:
                    anchor_to_term[anchor] = term
                    anchor_to_location[anchor] = (orgfile, lineno)
    return anchor_to_term, anchor_to_location


# ---------------------------------------------------------------------
# Body scanning with paragraph tracking.
# ---------------------------------------------------------------------

# Org-mode block delimiters we should skip entirely.
SKIP_BLOCK_BEGIN_RE = re.compile(
    r"^\s*#\+begin_(src|example|verse|quote|comment)\b", re.IGNORECASE
)
SKIP_BLOCK_END_RE = re.compile(
    r"^\s*#\+end_(src|example|verse|quote|comment)\b", re.IGNORECASE
)


def is_property_drawer_line(line):
    return bool(re.match(r"^\s*:[A-Z][A-Z_]*:", line))


def is_metadata_line(line):
    """Lines starting with #+ that are not block delimiters."""
    return line.lstrip().startswith("#+")


def is_heading_line(line):
    return bool(re.match(r"^\*+\s", line))


def is_blank_line(line):
    return line.strip() == ""


def line_contains_anchor_definition(line, anchor):
    """True if the line contains <<anchor>> --- it is the definition itself."""
    return f"<<{anchor}>>" in line


def line_already_links_anchor(line, anchor):
    """True if the line contains a [[anchor][...]] link."""
    return f"[[{anchor}]" in line


def build_term_pattern(term):
    """Build a case-insensitive word-boundary regex for a term.

    Handles simple plural (+s), simple punctuation, multi-word terms.
    The pattern matches the term itself, NOT links containing the term:
    a separate post-filter handles the link-context check.

    Word boundaries: a stricter version of \b that also rejects hyphen
    and underscore on either side, so "cross" does not match in
    "cross-reference" or "cross_section".
    """
    parts = re.split(r"\s+", term.strip())
    escaped_parts = [re.escape(p) for p in parts]
    body = r"\s+".join(escaped_parts)
    # (?<![\w\-]) and (?![\w\-]) reject letters, digits, underscores,
    # AND hyphens on either side of the term.
    pattern = rf"(?<![\w\-]){body}s?(?![\w\-])"
    return re.compile(pattern, re.IGNORECASE)


def find_term_occurrences_in_line(line, term_pattern):
    """Return (start, end) spans of term occurrences in line.

    Skips occurrences that are inside Org markup that already protects
    or contextualises them: links, verbatim, code, italic, bold.
    """
    spans = []
    # Build a list of "protected" character ranges in this line.
    protected = []
    # Org link forms: [[target]] and [[target][display]].
    for m in re.finditer(r"\[\[[^\]]+\](?:\[[^\]]+\])?\]", line):
        protected.append(m.span())
    # Verbatim ~...~ and =...=
    for m in re.finditer(r"~[^~\s][^~\n]*?[^~\s]~|~[^~\s]~", line):
        protected.append(m.span())
    for m in re.finditer(r"=[^=\s][^=\n]*?[^=\s]=|=[^=\s]=", line):
        protected.append(m.span())
    # Bare URLs.
    for m in re.finditer(r"https?://[^\s)]+", line):
        protected.append(m.span())

    for m in term_pattern.finditer(line):
        start, end = m.span()
        if any(ps <= start and pe >= end for ps, pe in protected):
            continue
        spans.append((start, end))
    return spans


# ---------------------------------------------------------------------
# Verbatim-aware scanning (--verbatim-aware flag).
#
# In the default mode, ~...~ verbatim spans are treated as protected and
# their contents are not scanned. This is correct when the verbatim is
# a literal token (a marking, an identifier, a code value), but it
# misses the sub-case where the verbatim's content IS a defined term.
# In that sub-case the canonical form is [[anchor][~Term~]] (a link
# wrapping the verbatim), not bare ~Term~.
#
# Verbatim-aware mode adds a second scan that finds verbatim spans
# whose ENTIRE content fullmatches a defined term, with two heuristic
# exceptions:
#
#   (e1) the verbatim is the SUBJECT of an Org definition-list item
#        (`- ~SUBJECT~ :: BODY`). This is exception (c) generalised:
#        a list of the term itself, used for example in SC-09's
#        token-by-token marking breakdown.
#   (e2) the verbatim is in the FIRST CELL of a table row
#        (`| ~SUBJECT~ | ... |` or `| ~A~ / ~B~ | ... |`). This is
#        exception (c) directly: a table of the term itself, used for
#        example in F-07's tab:fitting-markings.
#   (e3) the verbatim is on the line where the canonical glossary
#        definition lives. Already covered by exception (a) because
#        the scan_file loop skips such lines; no extra check needed.
# ---------------------------------------------------------------------

DEFINITION_LIST_VERBATIM_SUBJECT_RE = re.compile(
    r"^\s*-\s+~([^~\s][^~\n]*?[^~\s]|[^~\s])~\s*::\s"
)
TABLE_ROW_FIRST_CELL_RE = re.compile(r"^(\s*\|\s*)([^|]*?)\s*\|")
VERBATIM_SPAN_RE = re.compile(r"~([^~\s][^~\n]*?[^~\s]|[^~\s])~")


def get_verbatim_subject_spans(line):
    """Return a set of (start, end) spans for verbatims that occupy
    a 'subject of the term itself' position.

    - Definition-list subject:   ~X~ in `- ~X~ :: BODY`.
    - Table-row first cell:      every ~X~ within the first cell of
                                  `| ... | ... |`.

    These spans are excluded from the verbatim-aware scan: see
    exception (c) (table/list of the term itself) in the cross-
    reference rule, generalised here to verbatim subjects.
    """
    spans = set()
    if DEFINITION_LIST_VERBATIM_SUBJECT_RE.match(line):
        vm = VERBATIM_SPAN_RE.search(line)
        if vm:
            spans.add(vm.span())
    m = TABLE_ROW_FIRST_CELL_RE.match(line)
    if m and "~" in m.group(2):
        cell_offset = m.start(2)
        cell_text = m.group(2)
        for vm in VERBATIM_SPAN_RE.finditer(cell_text):
            spans.add((cell_offset + vm.start(), cell_offset + vm.end()))
    return spans


def find_verbatim_term_occurrences(line, term_pattern):
    """Find ~...~ verbatim spans whose content fullmatches the term.

    Returns a list of (term_start, term_end, verbatim_start,
    verbatim_end) tuples. The term span and verbatim span coincide
    minus the surrounding ~ characters.

    Only flags verbatims whose ENTIRE content is the term (with
    optional plural-s allowed by the term pattern). Verbatims like
    ~prefix Term suffix~ are not flagged: extracting just "Term" as
    a link target would be ambiguous and the surrounding text would
    suggest a deliberate composite token.

    Verbatim subjects of definition-list items and verbatims in the
    first cell of a table row are skipped (exception c, generalised).
    """
    subject_spans = get_verbatim_subject_spans(line)
    spans = []
    for vm in VERBATIM_SPAN_RE.finditer(line):
        v_start, v_end = vm.span()
        if (v_start, v_end) in subject_spans:
            continue
        v_content = vm.group(1)
        if term_pattern.fullmatch(v_content):
            # Span of the inner term within the line (between the ~).
            spans.append((v_start + 1, v_end - 1, v_start, v_end))
    return spans


# ---------------------------------------------------------------------
# Main scan.
# ---------------------------------------------------------------------


def scan_file(orgfile, anchor_to_term, anchor_to_location,
              verbatim_aware=False):
    """Scan one .org file for defects.

    A defect is: an occurrence of a term in prose that is not preceded
    in the same paragraph by a link to the term's canonical anchor,
    excluding the line where the term is defined.

    When verbatim_aware is True, also flag ~...~ verbatim spans whose
    entire content is a defined term. The canonical fix for those is
    a wrapping link of the form [[anchor][~Term~]].

    Returns list of (lineno, anchor, term, line, start, end) tuples.
    """
    defects = []
    with open(orgfile, "r", encoding="utf-8") as f:
        lines = f.readlines()

    # Track block state.
    in_skip_block = False

    # Track paragraph state per anchor: anchors linked in the current
    # paragraph (reset on blank line, heading, or block boundary).
    linked_in_paragraph = set()

    # Pre-build patterns once.
    patterns = {a: build_term_pattern(t) for a, t in anchor_to_term.items()}

    for lineno, line in enumerate(lines, 1):
        # Block tracking.
        if SKIP_BLOCK_BEGIN_RE.match(line):
            in_skip_block = True
            continue
        if SKIP_BLOCK_END_RE.match(line):
            in_skip_block = False
            continue
        if in_skip_block:
            continue

        # Paragraph break: blank line, heading, metadata, drawer.
        if (is_blank_line(line) or is_heading_line(line)
                or is_metadata_line(line) or is_property_drawer_line(line)):
            linked_in_paragraph = set()
            continue

        # Comment lines (not blocks, just lone comment lines starting #).
        # Org-mode treats lines starting with # (not #+) as comments.
        if line.lstrip().startswith("# ") or line.strip() == "#":
            continue

        # First, register any anchors already linked on this line to the
        # current paragraph's "linked" set.
        for anchor in anchor_to_term:
            if line_already_links_anchor(line, anchor):
                linked_in_paragraph.add(anchor)

        # Now scan for defects. Each anchor reports at most one defect
        # per line (first occurrence). Both the prose finder and (if
        # enabled) the verbatim-aware finder feed into this loop;
        # whichever produces an occurrence first wins.
        for anchor, term in anchor_to_term.items():
            # Exception (a): definition itself.
            if line_contains_anchor_definition(line, anchor):
                continue
            # Exception (b): successive use in same paragraph.
            if anchor in linked_in_paragraph:
                continue

            pattern = patterns[anchor]

            # Combine prose and (optionally) verbatim-content matches.
            spans = []
            for s, e in find_term_occurrences_in_line(line, pattern):
                spans.append((s, e))
            if verbatim_aware:
                for ts, te, _vs, _ve in find_verbatim_term_occurrences(
                        line, pattern):
                    spans.append((ts, te))
            if not spans:
                continue
            spans.sort()

            # Filter out cases where the line already has a link to this
            # anchor BEFORE the first occurrence.
            first_link = line.find(f"[[{anchor}]")
            for start, end in spans:
                if first_link != -1 and first_link < start:
                    # Already linked earlier on this same line: register
                    # for paragraph and stop counting on this line.
                    linked_in_paragraph.add(anchor)
                    break
                # Defect.
                defects.append((lineno, anchor, term, line.rstrip(),
                                start, end))
                # First defect on the line is enough; subsequent
                # occurrences would just spam.
                break

    return defects


def main():
    parser = argparse.ArgumentParser(
        prog="checkxref",
        description="Cross-reference defect detector for BSPPFit.",
        epilog="See DevelopmentStandard.org §Cross-Referencing Defined Terms.",
    )
    parser.add_argument(
        "doc_dir",
        nargs="?",
        default=".",
        help="Directory of .org files to scan (default: .)",
    )
    parser.add_argument(
        "--brief",
        action="store_true",
        help="One-line-per-defect output suitable for grep",
    )
    parser.add_argument(
        "--summary",
        action="store_true",
        help="Print only counts per term, not individual defects",
    )
    parser.add_argument(
        "--include-mass",
        action="store_true",
        help=("Include grammatical-mass terms (fitting, port, male, "
              "female, cross, tap, pitch, bore) in the scan. By default "
              "these are excluded per the fourth exception of the rule."),
    )
    parser.add_argument(
        "--verbatim-aware",
        action="store_true",
        help=("Also flag ~...~ verbatim spans whose entire content is "
              "a defined term. The canonical fix for those is a link "
              "wrapping the verbatim: [[anchor][~Term~]]. Off by "
              "default (the conservative, opt-in choice) because most "
              "verbatims are deliberately literal tokens."),
    )
    args = parser.parse_args()

    doc_dir = Path(args.doc_dir)
    if not doc_dir.is_dir():
        print(f"checkxref: '{doc_dir}' is not a directory", file=sys.stderr)
        return 2

    anchor_to_term, anchor_to_location = enumerate_anchors(doc_dir)
    if not anchor_to_term:
        print(f"checkxref: no <<term-...>> anchors found in '{doc_dir}'",
              file=sys.stderr)
        return 2

    # Apply grammatical-mass exclusion unless --include-mass.
    if not args.include_mass:
        excluded = set(anchor_to_term) & GRAMMATICAL_MASS_TERMS
        for a in excluded:
            del anchor_to_term[a]

    all_defects = []  # (file, lineno, anchor, term, line)
    for orgfile in sorted(doc_dir.rglob("*.org")):
        defects = scan_file(orgfile, anchor_to_term, anchor_to_location,
                             verbatim_aware=args.verbatim_aware)
        for d in defects:
            all_defects.append((orgfile,) + d)

    if not all_defects:
        print(f"checkxref: 0 defects in {len(anchor_to_term)} anchored terms.")
        return 0

    if args.summary:
        per_anchor = {}
        for orgfile, lineno, anchor, term, line, s, e in all_defects:
            per_anchor[anchor] = per_anchor.get(anchor, 0) + 1
        print(f"checkxref: {len(all_defects)} defects across "
              f"{len(per_anchor)} terms")
        for anchor, count in sorted(per_anchor.items(),
                                     key=lambda x: (-x[1], x[0])):
            term = anchor_to_term[anchor]
            print(f"  {count:4d}  {anchor:35s}  ({term})")
        return 1

    if args.brief:
        for orgfile, lineno, anchor, term, line, s, e in all_defects:
            print(f"{orgfile}:{lineno}: {anchor}  {term!r}")
        return 1

    # Default verbose output.
    print(f"checkxref: {len(all_defects)} defects.\n")
    for orgfile, lineno, anchor, term, line, s, e in all_defects:
        print(f"{orgfile}:{lineno}: {anchor}")
        print(f"  term:    {term!r}")
        print(f"  context: {line.strip()}")
        # Show what the linked form would look like.
        head = line[:s]
        match = line[s:e]
        tail = line[e:]
        print(f"  fix:     {head}[[{anchor}][{match}]]{tail}".rstrip())
        print()

    return 1


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