#!/usr/bin/env python3
# orgdoc-lint --- a hazard checker for Org-mode documents built with the
# orgdoc-kit toolchain (LuaLaTeX + Org HTML export).
#
# It reports, as `file:line:col: CODE message`, the lightweight-markup
# hazards that leak into a LaTeX/HTML build but are invisible in the
# Org source --- the ones the kit's standard document warns about and
# that are otherwise found only by reading the generated .tex.
#
# It REPORTS; it never edits. Several hazards (especially the "~" used
# as an "approximately" sign versus as verbatim markup) need a human eye
# to resolve correctly, so the tool flags and lets you fix.
#
# Exit status: 0 if no findings (or only findings below the failure
# threshold), 1 if findings at or above it, 2 on usage error. This makes
# it usable as a `make lint` target and a pre-commit hook.
#
# Usage:
#   orgdoc-lint [options] FILE.org [FILE.org ...]
# Options:
#   --select=CODES     comma-separated list of codes to check (default:
#                      all except OD001; OD001 is opt-in, see below)
#   --ignore=CODES     comma-separated list of codes to skip
#   --max-line=N       also flag source lines longer than N (default: off)
#   --quiet            print only the summary line
#   --no-color         disable ANSI colour
#   --explain=CODE     print the long description of a code and exit
#   --list             list all codes and exit
#   --version          print version and exit
#
# Codes:
#   OD001  underscore in processed text (subscript inductor) --- OPT-IN,
#          not run by default; see DEFAULT_CODES below for why
#   OD002  verbatim span crosses a line break
#   OD003  verbatim span contains its own delimiter
#   OD004  closing marker abuts a character that blocks span close
#   OD005  bare backslash-command in prose
#   OD006  literal prime / double-prime (U+2032 / U+2033)
#   OD007  line longer than --max-line (opt-in)

import sys
import re
import os

VERSION = "1.4.0"

CODES = {
    "OD001": "underscore in processed text (subscript inductor) [opt-in]",
    "OD002": "verbatim span crosses a line break",
    "OD003": "verbatim span contains its own delimiter",
    "OD004": "closing marker abuts a character that blocks the span close",
    "OD005": "bare backslash-command in prose",
    "OD006": "literal prime / double-prime (U+2032 / U+2033)",
    "OD007": "line longer than the configured maximum",
}

# Codes enabled by default (what a bare `orgdoc-lint FILE...`, with no
# --select, runs). OD001 is a full member of CODES --- it still appears
# in --list, --explain, and can still be run with --select=OD001 --- but
# is not run by default: since 2026-08-31, this project's own .org files
# all carry `#+OPTIONS: ^:{}`, which removes the underlying hazard OD001
# exists to catch project-wide (see OD001's own EXPLAIN text). A file
# that does NOT set `^:{}` --- e.g. if this tool is reused in a different
# project that hasn't adopted the option --- can still opt back in with
# --select=OD001,<whatever else is wanted>.
DEFAULT_CODES = set(CODES) - {"OD001"}

# Severity: "error" = silent corruption or a build failure (must fix);
# "warning" = fragile construct that often renders acceptably but is not
# guaranteed to (review). OD001/005/006 silently corrupt or break the
# build; OD002/003/004/007 are fragile-but-often-fine.
SEVERITY = {
    "OD001": "error", "OD005": "error", "OD006": "error",
    "OD002": "warning", "OD003": "warning", "OD004": "warning",
    "OD007": "warning",
}

EXPLAIN = {
    "OD001": """OD001 --- underscore in processed text (subscript inductor) [opt-in]

With Org's default `#+OPTIONS: ^:t`, a bare underscore is a subscript
inductor in ALL processed text: body prose, tables, headings, and
non-verbatim blocks (quote/center/verse). An identifier such as
`body_shape` written without markup exports to LaTeX as
`body\\textsubscript{shape}` --- "body" with a "shape" subscript.

As of 2026-08-31 this project sets `#+OPTIONS: ^:{}` in every .org
file's preamble, which changes Org's own interpretation project-wide:
a bare `a_b` is left as plain text; only the explicit-braces form
`a_{b}` is read as a subscript. Verified directly against this
project's export toolchain (LaTeX and HTML, prose/headings/lists/
tables) before relying on it. This removes the hazard OD001 exists to
catch for ordinary processed text, so OD001 is no longer run by
default; the several hundred identifiers already wrapped from before
this fix remain correct (a wrapped span is unaffected by `^:` either
way) and were deliberately left as-is rather than bulk-unwrapped.

Fix, if you ever do need it (a file that has not adopted `^:{}`, or
this tool reused in a project that hasn't): wrap the identifier in
code markup, e.g. ~body_shape~ or =body_shape=; run with
--select=OD001 to check for it explicitly.

Residual case `^:{}` does NOT cover: a raw export snippet
(`@@latex:...@@`) is passed to the LaTeX backend completely
unprocessed by Org --- `^:{}` only governs how Org itself reads
ordinary prose, and Org does not read the inside of an export snippet
at all. A literal underscore written there, outside of the snippet's
own `$...$`/`\\(...\\)` math delimiters if any, needs the same manual
`\\_` escaping it would need in hand-written LaTeX, regardless of this
option. Not currently checked by this tool (no such content exists in
this project's docs to justify the added complexity of recognising
export-snippet spans); if you add raw LaTeX literals with underscores,
escape them by hand the way you would outside Org entirely.""",
    "OD002": """OD002 --- verbatim span crosses a line break

A ~...~ code span or =...= verbatim span must open and close on the same
source line. When it wraps to the next line, Org does not treat it as a
span: the markers render literally and any underscores inside leak as
subscripts.

Fix: keep the whole span on one line, or use a #+begin_example block for
genuinely multi-line literal text.""",
    "OD003": """OD003 --- verbatim span contains its own delimiter

A =...= span cannot contain a literal '='; a ~...~ span cannot contain a
literal '~'. The first inner delimiter closes the span early, leaving the
rest exposed.

Fix: use the OTHER delimiter for content that contains one of them. To
show `d = d - 2*k` (has '='), wrap it in tildes: ~d = d - 2*k~. To show a
home directory path (has '~'), use equals: =/home/you= or write $HOME.""",
    "OD004": """OD004 --- closing marker abuts a character that blocks the span close

Org closes an emphasis/verbatim span only when the closing marker is
followed by whitespace, end of line, or one of  - . , ; : ! ? ' ) } [ "
A closing ~ or = immediately followed by '/', '+', '(' (and similar) does
NOT close the span; the markers render literally and the contents leak.

Fix: extend the span to include the trailing text as one unit
(~union_tail_a/b~ instead of ~union_tail_a~/b), or insert a space, or
reword.""",
    "OD005": """OD005 --- bare backslash-command in prose

A backslash-command written in running prose (e.g. \\nouppercase,
\\listoffigures, \\leftmark) is passed through to LaTeX as a live command.
At best it errors ("undefined control sequence"); at worst it silently
executes (\\listoffigures injects a spurious list into the PDF).

Backslash-commands INSIDE a LaTeX math span (\\( ... \\), \\[ ... \\],
$ ... $) are legitimate and exempt -- \\times, \\geq and the like belong
there. The check fires only outside math (and outside verbatim).

Fix: wrap it in code markup: ~\\listoffigures~.""",
    "OD006": """OD006 --- literal prime / double-prime (U+2032 / U+2033)

The literal prime (\u2032) and double-prime (\u2033) characters are not in the
default Latin Modern font and trigger "Missing character" warnings; they
also invite confusion with the ASCII apostrophe.

Fix: write them in a math span --- $3'$ for three primes, $6''$ for six
double-primes --- or use an explicit unit (3 ft, 6 in).""",
    "OD007": """OD007 --- line longer than the configured maximum

Opt-in stylistic check (enable with --max-line=N). Long source lines are
harder to review and make OD002/OD004 spans more likely to wrap. Not a
build hazard on its own.""",
}

# --- Org emphasis grammar helpers -------------------------------------
# Characters allowed immediately BEFORE an opening marker.
PRE_OK = set(" \t-({'\"\u00a0/")
# Characters allowed immediately AFTER a closing marker (Org's "post").
# Org's documented set is whitespace and  - . , ; : ! ? ' ) } [ "
# plus we treat ']' and the table cell separator '|' as valid closers
# (']' is a standard bracket close; '|' bounds a table cell like whitespace).
POST_OK = set(" \t-.,;:!?')}[]|\"\u00a0")
# Characters after a closing marker that are KNOWN to block the close
# and commonly cause leaks (subset reported as OD004 when a span looked
# intended). We report the common offenders.
POST_BLOCK = set("/+(*=<>")

VERBATIM_BLOCKS = {"example", "src", "export"}

IDENT = re.compile(r"[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_*]+|_[A-Za-z][A-Za-z0-9_]*")
# A backslash-command: backslash + >=2 letters, not preceded by another backslash.
BARE_CMD = re.compile(r"(?<!\\)\\[a-zA-Z]{2,}")


class Finding:
    # fix, when not None, is a (start_col0, end_col0, replacement) tuple
    # describing an in-place edit on the finding's line: characters
    # [start_col0, end_col0) are replaced by `replacement`. Columns are
    # 0-based half-open. Only the unambiguous codes (OD001, OD006) set it;
    # the span codes (OD002/3/4) need human judgement and leave it None.
    __slots__ = ("path", "line", "col", "code", "msg", "text", "fix")

    def __init__(self, path, line, col, code, msg, text, fix=None):
        self.path = path
        self.line = line
        self.col = col
        self.code = code
        self.msg = msg
        self.text = text
        self.fix = fix


def find_spans(line):
    """Return (spans, ok) where spans is a list of (start, end, delim) for
    well-formed ~...~ and =...= spans on this line, and ok flags whether
    every marker was paired. Uses Org's pre/post rules and treats a '~'
    or '=' that is an 'approximately'/operator sign (followed by a digit
    or whitespace, or surrounded by spaces) as NOT a marker."""
    spans = []
    i = 0
    n = len(line)
    while i < n:
        ch = line[i]
        if ch in "~=":
            prev = line[i - 1] if i > 0 else ""
            nxt = line[i + 1] if i + 1 < n else ""
            # opener candidate: pre char OK, next char is not space/eol
            pre_ok = (prev == "" or prev in PRE_OK)
            if pre_ok and nxt != "" and not nxt.isspace():
                # NOTE on the "approximately" sign: a '~' followed by a digit
                # (e.g. ~10 %) is only literal when it has no matching closer.
                # We therefore do NOT reject digit-openers here; instead we
                # require a valid closer below. If none is found, no span is
                # recorded and the '~' is left as literal text. This is what
                # Org itself does, and it avoids the false positives that a
                # blanket digit-rejection caused (e.g. ~2 * MIN_WALL + len~).
                # try to find a matching closer of the same delim
                j = i + 1
                found = -1
                while j < n:
                    if line[j] == ch:
                        after = line[j + 1] if j + 1 < n else ""
                        before = line[j - 1]
                        # a valid closer: preceding char is not a space, and
                        # the following char is a valid post char or EOL
                        if before != " " and (after == "" or after in POST_OK):
                            found = j
                            break
                        # a closer-looking marker blocked by post char:
                        if before != " " and after in POST_BLOCK:
                            # record as a blocked-close candidate
                            found = -2
                            blocked_at = j
                            break
                    j += 1
                if found >= 0:
                    spans.append((i, found, ch))
                    i = found + 1
                    continue
                elif found == -2:
                    # blocked close: report handled by caller; treat span as
                    # spanning to the blocked marker for masking purposes
                    spans.append((i, blocked_at, ch))
                    i = blocked_at + 1
                    continue
                # no closer on this line -> dangling (caller detects via parity)
        i += 1
    return spans


def masked(line, spans):
    """Return the line with verbatim span interiors replaced by spaces, so
    identifier/command scanning ignores protected content."""
    chars = list(line)
    for s, e, _ in spans:
        for k in range(s, e + 1):
            chars[k] = " "
    return "".join(chars)


# Org links: [[target]] or [[target][description]]. The TARGET (a URL or
# file path) is not subject to lightweight-markup processing -- an
# underscore there does not become a subscript -- so it must be masked
# before the identifier/command scan. The DESCRIPTION, if present, IS
# processed, so it is left visible. Bare inline URLs (http/https/file/
# mailto) are likewise not processed and are masked too.
LINK_RE = re.compile(r"\[\[([^\]]*?)\](?:\[([^\]]*?)\])?\]")
BARE_URL_RE = re.compile(r"\b(?:https?|ftp|file|mailto):[^\s\]\[<>]+")


def mask_links(line):
    """Blank Org link TARGETS and bare URLs, keeping link descriptions
    (which are processed) visible. Returns the masked line."""
    chars = list(line)

    def blank(a, b):
        for k in range(a, b):
            if chars[k] not in " \t":
                chars[k] = " "

    for m in LINK_RE.finditer(line):
        # mask the target (group 1); keep the description (group 2) text
        ts, te = m.start(1), m.end(1)
        blank(ts, te)
        # also blank the bracket/double-bracket punctuation so it does not
        # confuse later scans (harmless: not identifier/command chars)
    for m in BARE_URL_RE.finditer(line):
        # only mask bare URLs that are NOT inside a [[...]] we already saw;
        # blanking is idempotent so overlap is fine
        blank(m.start(), m.end())
    return "".join(chars)


# LaTeX math spans. Inside math, a backslash-command (\times, \geq, ...)
# is legitimate live LaTeX, an underscore is an intended subscript, and a
# prime is fine -- so math interiors must be exempt from OD001, OD005 and
# OD006. We recognise the inline forms \( ... \) and $ ... $, and the
# display forms \[ ... \] and $$ ... $$. (Org also exports these as
# math.) The single-dollar form is constrained to avoid eating prose
# that merely contains a currency dollar: it must open with $ directly
# followed by a non-space and close with a non-space directly before $.
MATH_RE = re.compile(
    r"\\\(.*?\\\)"            # \( ... \)
    r"|\\\[.*?\\\]"           # \[ ... \]
    r"|\$\$.*?\$\$"           # $$ ... $$
    r"|\$(?=\S)(?:\\\$|[^$])*?\S\$"   # $ ...non-space-bounded... $
    r"|\$\S\$",               # $x$ single-token
)


def mask_math(line):
    """Blank the INTERIOR of LaTeX math spans, leaving the delimiters in
    place. Returns the masked line."""
    chars = list(line)
    for m in MATH_RE.finditer(line):
        # blank everything strictly inside the delimiters; the delimiters
        # themselves (\( \) \[ \] $ $$) carry no identifier/command risk.
        for k in range(m.start(), m.end()):
            if chars[k] not in " \t":
                chars[k] = " "
    return "".join(chars)


def lint_file(path, selected, max_line):
    findings = []
    try:
        with open(path, encoding="utf-8") as fh:
            lines = fh.read().split("\n")
    except OSError as exc:
        sys.stderr.write("orgdoc-lint: cannot read %s: %s\n" % (path, exc))
        return None

    inblock = None  # current #+begin_<type> if any
    for idx, line in enumerate(lines):
        lineno = idx + 1
        st = line.strip()

        mb = re.match(r"#\+begin_([a-zA-Z]+)", st)
        me = re.match(r"#\+end_([a-zA-Z]+)", st)
        if mb:
            inblock = mb.group(1).lower()
            continue
        if me:
            inblock = None
            continue

        # Verbatim blocks and fixed-width lines: skip content checks.
        if inblock in VERBATIM_BLOCKS:
            continue
        if st.startswith(":") and (st == ":" or line.lstrip().startswith(": ") or st == line.strip() and re.match(r"^:( |$)", st)):
            # Org fixed-width line (": ..."), protected.
            continue
        # Skip pure keyword/directive lines for command/identifier checks,
        # but still allow span checks (a #+CAPTION can leak too); we keep it
        # simple and skip #+ lines entirely except TITLE/SUBTITLE/CAPTION.
        is_directive = bool(re.match(r"^\s*#\+", line))

        # OD006: literal primes anywhere (even directives). Auto-fix:
        # replace U+2032 (prime) with $\'$ and U+2033 (double prime) with
        # $\'\'$ -- the math-mode forms that render in the default font.
        if "OD006" in selected:
            for m in re.finditer(r"[\u2032\u2033]", line):
                glyph = m.group(0)
                repl = "$'$" if glyph == "\u2032" else "$''$"
                fix = (m.start(), m.end(), repl)
                findings.append(Finding(path, lineno, m.start() + 1, "OD006",
                                         "literal prime/double-prime", line, fix))

        # Build the well-formed span list for masking + span checks.
        spans = find_spans(line)

        # OD002: a verbatim/code span that opens but does not close on this
        # line. We only flag a DANGLING marker that looks like an intended
        # span opener -- preceded by a valid pre-char and followed by a
        # "code-ish" character (letter, '\', '(', '[', '{', '$', '/', '.').
        # A dangling '~' before a digit or space is the "approximately"
        # sign, not markup, and is left alone. This keeps the check quiet on
        # prose while still catching real split spans.
        if "OD002" in selected and not is_directive:
            covered = [(s, e) for s, e, d in spans]

            def in_any(pos):
                return any(s <= pos <= e for s, e in covered)

            CODEISH = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\\([{$/._"
            for delim in "~=":
                dangling = False
                dcol = 0
                for k, ch in enumerate(line):
                    if ch != delim or in_any(k):
                        continue
                    prev = line[k - 1] if k > 0 else ""
                    nxt = line[k + 1] if k + 1 < len(line) else ""
                    pre_ok = (prev == "" or prev in PRE_OK)
                    if pre_ok and nxt and nxt in CODEISH:
                        # looks like an opener but no well-formed span covers it
                        dangling = True
                        dcol = k + 1
                        break
                if dangling:
                    findings.append(Finding(path, lineno, dcol, "OD002",
                                             "verbatim %s span appears to open but not close on this line"
                                             % ("code (~)" if delim == "~" else "verbatim (=)"),
                                             line))
                    break

        # OD003: internal delimiter inside a well-formed span.
        if "OD003" in selected:
            for s, e, d in spans:
                interior = line[s + 1:e]
                if d in interior:
                    findings.append(Finding(path, lineno, s + 1, "OD003",
                                             "%s span contains a literal '%s'" % (d, d),
                                             line))

        # OD004: a closing marker immediately followed by a blocking char.
        if "OD004" in selected:
            for s, e, d in spans:
                after = line[e + 1] if e + 1 < len(line) else ""
                if after in POST_BLOCK:
                    findings.append(Finding(path, lineno, e + 1, "OD004",
                                             "closing '%s' is followed by '%s', which blocks the span close"
                                             % (d, after), line))

        mline = masked(line, spans)
        # Also blank link targets and bare URLs: an underscore or a
        # backslash-command in a URL/path is not processed by Org, so it is
        # not a hazard. (Link descriptions remain visible -- they ARE
        # processed.) This prevents false OD001/OD005 on, e.g., a Wikimedia
        # File:Some_Name.svg URL.
        mline = mask_links(mline)
        # And blank LaTeX math interiors: \times, \geq, a subscript
        # underscore, etc. are legitimate inside \( \), \[ \] or $ $, not
        # hazards. This prevents false OD001/OD005 in math.
        mline = mask_math(mline)

        # OD001: underscore identifier in processed text (outside spans).
        if "OD001" in selected and not is_directive:
            for m in IDENT.finditer(mline):
                a, b = m.start(), m.end()
                before = mline[a - 1] if a > 0 else ""
                after = mline[b] if b < len(mline) else ""
                # skip if adjacent to a backslash (likely a LaTeX command arg)
                if before == "\\":
                    continue
                # Auto-fix: wrap the identifier in ~...~ -- but ONLY when it is
                # safe. Unsafe (left for the human) when the character after
                # would block the closing '~' (POST_BLOCK), since that just
                # trades an OD001 for an OD004; or when a markup marker is
                # already adjacent. Columns are read from the ORIGINAL line
                # (mline and line are positionally aligned).
                real_after = line[b] if b < len(line) else ""
                real_before = line[a - 1] if a > 0 else ""
                fix = None
                if (real_after not in POST_BLOCK
                        and real_before not in "~="
                        and real_after not in "~="):
                    fix = (a, b, "~" + line[a:b] + "~")
                findings.append(Finding(path, lineno, a + 1, "OD001",
                                         "underscore identifier '%s' in processed text"
                                         % m.group(0), line, fix))

        # OD005: bare backslash-command in prose (outside spans, not directive).
        if "OD005" in selected and not is_directive:
            for m in BARE_CMD.finditer(mline):
                findings.append(Finding(path, lineno, m.start() + 1, "OD005",
                                         "bare backslash-command '%s' in prose"
                                         % m.group(0), line))

        # OD007: long line (opt-in).
        if "OD007" in selected and max_line and len(line) > max_line:
            findings.append(Finding(path, lineno, max_line + 1, "OD007",
                                     "line is %d chars (max %d)" % (len(line), max_line),
                                     line))

    return findings


def colorize(s, code, enable):
    if not enable:
        return s
    colors = {"path": "\033[1m", "code": "\033[33m", "reset": "\033[0m",
              "caret": "\033[31m"}
    return colors.get(code, "") + s + colors["reset"]


def apply_fixes(path, findings):
    """Apply the auto-fixable findings (those with a .fix) to `path`'s
    lines in memory and return (new_lines, n_applied, n_skipped). Edits
    are applied per line, rightmost-first, so earlier columns stay valid.
    Overlapping fixes on one line are skipped defensively."""
    try:
        with open(path, encoding="utf-8") as fh:
            lines = fh.read().split("\n")
    except OSError:
        return None, 0, 0
    per_line = {}
    for f in findings:
        if f.fix is not None:
            per_line.setdefault(f.line, []).append(f.fix)
    applied = skipped = 0
    for lineno, fixes in per_line.items():
        idx = lineno - 1
        if idx >= len(lines):
            skipped += len(fixes)
            continue
        s = lines[idx]
        used = []
        for a, b, repl in sorted(fixes, key=lambda x: -x[0]):
            if any(not (b <= ua or a >= ub) for ua, ub in used):
                skipped += 1
                continue
            if a < 0 or b > len(s):
                skipped += 1
                continue
            s = s[:a] + repl + s[b:]
            used.append((a, b))
            applied += 1
        lines[idx] = s
    return lines, applied, skipped


def unified_diff(path, old_lines, new_lines):
    import difflib
    return "".join(difflib.unified_diff(
        [l + "\n" for l in old_lines],
        [l + "\n" for l in new_lines],
        fromfile=path, tofile=path + " (fixed)"))


def findings_to_json(all_findings):
    import json
    out = []
    for f in all_findings:
        rec = {
            "path": f.path, "line": f.line, "col": f.col,
            "code": f.code, "severity": SEVERITY.get(f.code, "warning"),
            "message": f.msg, "fixable": f.fix is not None,
        }
        if f.fix is not None:
            a, b, repl = f.fix
            rec["fix"] = {"start_col": a + 1, "end_col": b + 1,
                          "replacement": repl}
        out.append(rec)
    return json.dumps(out, indent=2, ensure_ascii=False)


def main(argv):
    selected = set(DEFAULT_CODES)
    max_line = None
    quiet = False
    color = sys.stdout.isatty()
    errors_only = False
    fail_on = "warning"   # exit 1 if any finding at or above this severity
    do_fix = False
    do_diff = False
    out_format = "text"
    files = []

    i = 0
    while i < len(argv):
        a = argv[i]
        if a == "--version":
            print("orgdoc-lint %s" % VERSION)
            return 0
        elif a == "--list":
            for c in sorted(CODES):
                print("%s  [%s]  %s" % (c, SEVERITY[c], CODES[c]))
            return 0
        elif a.startswith("--explain"):
            code = a.split("=", 1)[1] if "=" in a else (argv[i + 1] if i + 1 < len(argv) else "")
            if "=" not in a:
                i += 1
            print(EXPLAIN.get(code.upper(), "unknown code: %s" % code))
            return 0
        elif a.startswith("--select="):
            selected = set(c.strip().upper() for c in a.split("=", 1)[1].split(","))
        elif a.startswith("--ignore="):
            for c in a.split("=", 1)[1].split(","):
                selected.discard(c.strip().upper())
        elif a.startswith("--max-line="):
            max_line = int(a.split("=", 1)[1])
            selected.add("OD007")
        elif a == "--errors-only":
            errors_only = True
        elif a.startswith("--fail-on="):
            fail_on = a.split("=", 1)[1].strip().lower()
            if fail_on not in ("error", "warning", "never"):
                sys.stderr.write("orgdoc-lint: --fail-on must be error|warning|never\n")
                return 2
        elif a == "--fix":
            do_fix = True
        elif a == "--diff":
            do_diff = True
        elif a.startswith("--format="):
            out_format = a.split("=", 1)[1].strip().lower()
            if out_format not in ("text", "json"):
                sys.stderr.write("orgdoc-lint: --format must be text|json\n")
                return 2
        elif a == "--quiet":
            quiet = True
        elif a == "--no-color":
            color = False
        elif a == "--help" or a == "-h":
            print(_USAGE)
            return 0
        elif a.startswith("-"):
            sys.stderr.write("orgdoc-lint: unknown option %s\n" % a)
            return 2
        else:
            files.append(a)
        i += 1

    if not files:
        sys.stderr.write(_USAGE)
        return 2

    if max_line is None:
        selected.discard("OD007")
    if errors_only:
        selected = set(c for c in selected if SEVERITY.get(c) == "error")

    # Collect findings for every file first (needed for json and fix modes).
    per_file = []
    for path in files:
        res = lint_file(path, selected, max_line)
        if res is None:
            return 2
        per_file.append((path, res))
    all_findings = [f for _, fs in per_file for f in fs]

    # JSON output: emit structured findings and exit (no fixing).
    if out_format == "json":
        print(findings_to_json(all_findings))
        if fail_on == "never":
            return 0
        bad = [f for f in all_findings
               if fail_on == "warning"
               or (fail_on == "error" and SEVERITY.get(f.code) == "error")]
        return 1 if bad else 0

    # --fix / --diff: apply the auto-fixable findings (OD001, OD006).
    if do_fix or do_diff:
        total_applied = total_skipped = 0
        fixable_total = sum(1 for f in all_findings if f.fix is not None)
        for path, fs in per_file:
            old_lines = open(path, encoding="utf-8").read().split("\n")
            new_lines, applied, skipped = apply_fixes(path, fs)
            if new_lines is None:
                sys.stderr.write("orgdoc-lint: cannot rewrite %s\n" % path)
                return 2
            total_applied += applied
            total_skipped += skipped
            if applied == 0:
                continue
            if do_fix:
                with open(path, "w", encoding="utf-8") as fh:
                    fh.write("\n".join(new_lines))
            if do_diff:
                sys.stdout.write(unified_diff(path, old_lines, new_lines))
        verb = "would fix" if (do_diff and not do_fix) else "fixed"
        unfixable = len(all_findings) - fixable_total
        print("orgdoc-lint: %s %d finding%s "
              "(%d auto-fixable, %d need review)%s"
              % (verb, total_applied, "" if total_applied == 1 else "s",
                 fixable_total, unfixable,
                 ("; %d skipped" % total_skipped) if total_skipped else ""))
        if fail_on == "never":
            return 0
        remaining = unfixable if do_fix else len(all_findings)
        return 1 if remaining else 0

    # Default: report findings as text.
    total = 0
    fail_count = 0
    by_code = {}
    for path, fs in per_file:
        for f in fs:
            total += 1
            by_code[f.code] = by_code.get(f.code, 0) + 1
            sev = SEVERITY.get(f.code, "warning")
            if fail_on == "warning" and sev in ("warning", "error"):
                fail_count += 1
            elif fail_on == "error" and sev == "error":
                fail_count += 1
            if not quiet:
                loc = colorize("%s:%d:%d" % (f.path, f.line, f.col), "path", color)
                tag = "%s %s%s" % (f.code, sev, " (fixable)" if f.fix is not None else "")
                code = colorize(tag, "code", color)
                print("%s: %s %s" % (loc, code, f.msg))
                # show the offending line and a caret
                print("    %s" % f.text)
                print("    %s%s" % (" " * (f.col - 1), colorize("^", "caret", color)))

    if not quiet or total:
        parts = ", ".join("%s=%d" % (c, by_code[c]) for c in sorted(by_code))
        err = sum(by_code.get(c, 0) for c in by_code if SEVERITY.get(c) == "error")
        warn = total - err
        nfix = sum(1 for f in all_findings if f.fix is not None)
        print("orgdoc-lint: %d finding%s (%d error%s, %d warning%s%s)%s" %
              (total, "" if total == 1 else "s",
               err, "" if err == 1 else "s",
               warn, "" if warn == 1 else "s",
               ", %d auto-fixable" % nfix if nfix else "",
               (" [" + parts + "]") if parts else ""))

    if fail_on == "never":
        return 0
    return 1 if fail_count else 0


_USAGE = """usage: orgdoc-lint [options] FILE.org [FILE.org ...]
  --select=CODES    only these codes (e.g. --select=OD001,OD002)
  --ignore=CODES    skip these codes
  --errors-only     check only error-severity codes (OD001, OD005, OD006)
  --fail-on=LEVEL   exit 1 on error|warning|never (default: warning)
  --fix             apply the auto-fixable findings in place (OD001, OD006)
  --diff            show a unified diff of what --fix would change
                    (with --fix: write AND show the diff)
  --format=FMT      output format: text (default) or json
  --max-line=N      also flag lines longer than N (enables OD007)
  --quiet           summary only
  --no-color        disable colour
  --explain=CODE    print a code's long description
  --list            list codes with severities
  --version         print version

Only the unambiguous codes are auto-fixable: OD001 (wrap the identifier
in ~...~) and OD006 (prime glyph to a math span). The span codes
(OD002/OD003/OD004) need human judgement -- join, reword, or re-span --
and are never auto-edited; --fix leaves them for review. An OD001 whose
identifier abuts a span-blocking character is also left for review (auto-
wrapping it would only trade an OD001 for an OD004).

Exit status: 0 = nothing left at/above the --fail-on level, 1 = findings
remain, 2 = usage error.
"""

if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
