#!/usr/bin/env python3
# $Id: applyxref 336 2026-09-05 13:20:12Z peter $
#
# applyxref --- bulk cross-reference link fixer 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.
#
# Companion to checkxref. Where checkxref REPORTS defects under
# DevelopmentStandard.org §Cross-Referencing Defined Terms, applyxref
# REWRITES the source files to apply the every-occurrence rule (with
# the four documented exceptions) automatically.
#
# applyxref is a one-shot tool intended for /bulk migrations/ such as
# the iteration 0.8.6 retroactive-audit pass on Doc/Taxonomy.org.
# Once a document's cross-references are clean, new content should be
# written with manual links and checkxref used for verification.
# applyxref is shipped because (a) bulk migrations may recur (e.g.
# when a new glossary term is added that should retroactively be
# linked everywhere it already appears), and (b) the project's
# reproducibility benefits from having every tool that touched the
# project tree available in the source.
#
# Usage:
#     applyxref FILE [FILE...]
#
# The script reads each FILE, applies link-fix transformations per
# the rule, and writes the modified file in place. Run checkxref
# afterward to verify.
#
# Exit codes:
#     0  successful run (regardless of how many fixes were applied)
#     1  no files specified or other invocation error
#
# Limitations: terms with internal punctuation (e.g. "Nipple, close")
# match across surface-level word boundaries, which is correct
# semantically but can produce unexpected display text in some
# contexts; manual review of the resulting diff is recommended.

import re
import sys
from pathlib import Path

# Inline glossary parsing and pattern building --- intentionally a
# self-contained script with no module imports beyond the standard
# library. Logic mirrors checkxref.

GLOSSARY_LINE_RE = re.compile(
    r"^\s*- \*([^*\n]+?)\* ::\s*<<(term-[a-z0-9-]+)>>"
)

GRAMMATICAL_MASS_TERMS = frozenset({
    "term-fitting", "term-port", "term-male", "term-female",
    "term-cross", "term-tap", "term-pitch", "term-bore",
})

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 enumerate_anchors(doc_dir):
    anchor_to_term = {}
    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)
                term = re.sub(r"\s*\([^)]*\)\s*$", "", term_label).strip()
                if anchor not in anchor_to_term:
                    anchor_to_term[anchor] = term
    return anchor_to_term


def build_term_pattern(term):
    parts = re.split(r"\s+", term.strip())
    escaped_parts = [re.escape(p) for p in parts]
    body = r"\s+".join(escaped_parts)
    pattern = rf"(?<![\w\-]){body}s?(?![\w\-])"
    return re.compile(pattern, re.IGNORECASE)


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


def is_metadata_line(line):
    return line.lstrip().startswith("#+")


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


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


# ---------------------------------------------------------------------
# Verbatim-aware helpers (mirror of checkxref). See checkxref for
# the design rationale and exception (e1)/(e2) discussion.
# ---------------------------------------------------------------------

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 the set of (start, end) spans for verbatim subjects of
    definition-list items or table-row first cells. These are exempted
    from the verbatim-aware fix per exception (c) generalised."""
    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)
        for vm in VERBATIM_SPAN_RE.finditer(m.group(2)):
            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 (term_start, term_end, verbatim_start, verbatim_end) tuples.
    Subjects of definition lists and table-row first cells are skipped.
    """
    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):
            spans.append((v_start + 1, v_end - 1, v_start, v_end))
    return spans


def fix_file(orgfile, anchor_to_term, verbatim_aware=False):
    """Read orgfile, apply link-fix transformations per the rule,
    return new contents and stats.

    When verbatim_aware is True, also wrap ~...~ verbatim spans whose
    entire content is a defined term as [[anchor][~Term~]]. The two
    documented exceptions (verbatim subjects of definition lists, and
    verbatims in the first cell of a table row) are skipped.
    """
    with open(orgfile, "r", encoding="utf-8") as f:
        lines = f.readlines()

    patterns = {a: build_term_pattern(t) for a, t in anchor_to_term.items()}

    in_skip_block = False
    linked_in_paragraph = set()
    fixes_applied = 0

    out_lines = []

    for line in lines:
        # Block tracking.
        if SKIP_BLOCK_BEGIN_RE.match(line):
            in_skip_block = True
            out_lines.append(line)
            continue
        if SKIP_BLOCK_END_RE.match(line):
            in_skip_block = False
            out_lines.append(line)
            continue
        if in_skip_block:
            out_lines.append(line)
            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()
            out_lines.append(line)
            continue

        # Lone comment lines.
        if line.lstrip().startswith("# ") or line.strip() == "#":
            out_lines.append(line)
            continue

        # Register any anchors already linked on this line.
        for anchor in anchor_to_term:
            if f"[[{anchor}]" in line:
                linked_in_paragraph.add(anchor)

        # Build a "protected" mask of character positions that should
        # not be modified (already inside links, verbatim, etc.).
        # We also want to skip the line if it contains <<anchor>>.
        skip_anchors_on_this_line = set()
        for anchor in anchor_to_term:
            if f"<<{anchor}>>" in line:
                skip_anchors_on_this_line.add(anchor)

        protected = []
        for m in re.finditer(r"\[\[[^\]]+\](?:\[[^\]]+\])?\]", line):
            protected.append(m.span())
        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())
        for m in re.finditer(r"https?://[^\s)]+", line):
            protected.append(m.span())

        # Find candidate fixes on this line.
        # Each candidate: (start, end, anchor, matched_text). The
        # matched_text is what gets wrapped: prose terms wrap to
        # [[anchor][Term]]; in verbatim-aware mode, verbatim wraps
        # to [[anchor][~Term~]] (the matched_text is the whole ~Term~).
        candidates = []
        for anchor, term in anchor_to_term.items():
            if anchor in skip_anchors_on_this_line:
                continue
            if anchor in linked_in_paragraph:
                continue

            pattern = patterns[anchor]
            for m in pattern.finditer(line):
                start, end = m.span()
                # Skip if inside a protected span.
                if any(ps <= start and pe >= end for ps, pe in protected):
                    continue
                # Skip if a link to this anchor already exists earlier
                # on this line.
                first_link = line.find(f"[[{anchor}]")
                if first_link != -1 and first_link < start:
                    linked_in_paragraph.add(anchor)
                    break
                candidates.append((start, end, anchor, line[start:end]))
                break  # one match per anchor per line is enough

            # Verbatim-aware additions: ~Term~ → [[anchor][~Term~]].
            if verbatim_aware and anchor not in linked_in_paragraph:
                # Re-test that no candidate for this anchor was already
                # added by the prose loop above (prefer prose if both).
                if not any(c[2] == anchor for c in candidates):
                    v_spans = find_verbatim_term_occurrences(line, pattern)
                    for _ts, _te, v_start, v_end in v_spans:
                        first_link = line.find(f"[[{anchor}]")
                        if first_link != -1 and first_link < v_start:
                            linked_in_paragraph.add(anchor)
                            break
                        # Wrap the WHOLE verbatim span (including the
                        # surrounding ~ characters) so the resulting
                        # link reads [[anchor][~Term~]].
                        candidates.append(
                            (v_start, v_end, anchor, line[v_start:v_end]))
                        break

        # Resolve overlaps: prefer the longest match, then earliest.
        # Specifically: when "dielectric union" and "union" both match
        # at overlapping positions, the longer one (term-dielectric-union)
        # wins; the shorter one is dropped to avoid corrupting the line.
        candidates.sort(key=lambda c: (-(c[1] - c[0]), c[0]))
        accepted = []
        for start, end, anchor, matched in candidates:
            if any((start < a_end and end > a_start)
                   for a_start, a_end, _, _ in accepted):
                continue
            accepted.append((start, end, anchor, matched))

        # Mark accepted anchors as linked in the paragraph.
        for _, _, anchor, _ in accepted:
            linked_in_paragraph.add(anchor)

        # Sort by start position, descending, so we apply replacements
        # from right to left and the offsets stay valid.
        accepted.sort(key=lambda c: -c[0])

        # Apply.
        new_line = line
        for start, end, anchor, matched in accepted:
            new_line = (new_line[:start] +
                        f"[[{anchor}][{matched}]]" +
                        new_line[end:])
            fixes_applied += 1

        out_lines.append(new_line)

    return "".join(out_lines), fixes_applied


def main():
    import argparse
    parser = argparse.ArgumentParser(
        prog="applyxref",
        description=("Bulk cross-reference link fixer for BSPPFit "
                     "documents. Companion to checkxref."),
        epilog="See DevelopmentStandard.org §Cross-Referencing Defined Terms.",
    )
    parser.add_argument(
        "files",
        nargs="+",
        help=".org files to rewrite in place",
    )
    parser.add_argument(
        "--verbatim-aware",
        action="store_true",
        help=("Also wrap ~...~ verbatim spans whose entire content is "
              "a defined term as [[anchor][~Term~]]. Verbatim subjects "
              "of definition lists and verbatims in the first cell of "
              "a table row are skipped (exception (c) generalised). "
              "Mirror of checkxref's --verbatim-aware flag."),
    )
    args = parser.parse_args()

    # Doc dir is parent of the first file.
    first = Path(args.files[0])
    doc_dir = first.parent
    anchor_to_term = enumerate_anchors(doc_dir)

    # Apply mass-term exclusion.
    for a in list(anchor_to_term):
        if a in GRAMMATICAL_MASS_TERMS:
            del anchor_to_term[a]

    total_fixes = 0
    for path in args.files:
        orgfile = Path(path)
        new_content, fixes = fix_file(orgfile, anchor_to_term,
                                       verbatim_aware=args.verbatim_aware)
        with open(orgfile, "w", encoding="utf-8") as f:
            f.write(new_content)
        print(f"{orgfile}: {fixes} fixes applied")
        total_fixes += fixes
    print(f"Total: {total_fixes} fixes")
    return 0


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