import json
import re
from pathlib import Path

from .module import AccordanceModule
from .encoding import decode, decode_apparatus
from .canon import (
    CANON_PROTESTANT,
    resolve_book,
    parse_reference as canon_parse_reference,
    osis_to_spaceless,
    spaceless_to_osis,
    chapter_count,
    BOOKS_BY_OSIS,
)
from .constants import (
    HERMENEIA_TITLE_MAP,
    _SKIP_PATTERNS,
    _NONENG_PATTERNS,
    _NOTES_WHITELIST,
)


# Canon-derived book lists (spaceless names for internal use).
# Computed lazily to avoid heavy CANON_PROTESTANT.books iteration
# on every import of this module.
class _LazyBookList:
    """Lazily computed immutable list of spaceless canonical book names."""

    def __init__(self, testament_filter=None):
        self._filter = testament_filter
        self._value: list | None = None

    def _ensure(self):
        if self._value is None:
            self._value = [
                osis_to_spaceless(b.osis)
                for b in CANON_PROTESTANT.books
                if self._filter is None or b.testament == self._filter
            ]
        return self._value

    def __contains__(self, item):
        return item in self._ensure()

    def __iter__(self):
        return iter(self._ensure())

    def __len__(self):
        return len(self._ensure())

    def __getitem__(self, idx):
        return self._ensure()[idx]


OT_BOOKS = _LazyBookList("ot")
NT_BOOKS = _LazyBookList("nt")
ALL_BOOKS = _LazyBookList(None)  # all books


def parse_reference(ref: str) -> tuple:
    """Parse a Bible reference like 'Matthew 3:5' into (book, chapter, verse).
    Book is returned in spaceless canonical form."""
    result = canon_parse_reference(ref, canon="protestant")
    if result is None:
        raise ValueError(f"Cannot parse reference: {ref}")
    book_obj = BOOKS_BY_OSIS.get(result.book_osis)
    if book_obj is None:
        raise ValueError(f"Unknown book in reference: {ref}")
    return (osis_to_spaceless(book_obj.osis), result.chapter, result.verse)


def _normalize_book(book_raw: str) -> str:
    """Normalize a book name to commentary-module spaceless canonical form."""
    resolved = resolve_book(book_raw)
    if resolved is None:
        # Fallback: strip spaces and title-case (backward compat)
        book = book_raw.replace(" ", "")
        if book and book[0].islower():
            book = book[0].upper() + book[1:]
        return book
    return osis_to_spaceless(resolved.osis)


def _parse_chapter(data: bytes) -> int | None:
    """Parse a chapter number from 'CHAPTER N' or 'CHAPTER I' (Roman numeral).

    Returns the chapter number (1-150) or None if not a chapter marker.
    """
    text = data.decode("latin-1", errors="replace")
    match = re.match(r"CHAPTER\s+(\S+)", text, re.IGNORECASE)
    if not match:
        return None

    num_str = match.group(1).rstrip(".")

    # Try Arabic digits first
    try:
        return int(num_str)
    except ValueError:
        pass

    # Try Roman numerals
    roman_map = {
        "I": 1,
        "II": 2,
        "III": 3,
        "IV": 4,
        "V": 5,
        "VI": 6,
        "VII": 7,
        "VIII": 8,
        "IX": 9,
        "X": 10,
        "XI": 11,
        "XII": 12,
        "XIII": 13,
        "XIV": 14,
        "XV": 15,
        "XVI": 16,
        "XVII": 17,
        "XVIII": 18,
        "XIX": 19,
        "XX": 20,
        "XXI": 21,
        "XXII": 22,
        "XXIII": 23,
        "XXIV": 24,
        "XXV": 25,
        "XXVI": 26,
        "XXVII": 27,
        "XXVIII": 28,
        "XXIX": 29,
        "XXX": 30,
        "XXXI": 31,
        "XXXII": 32,
        "XXXIII": 33,
        "XXXIV": 34,
        "XXXV": 35,
        "XXXVI": 36,
        "XXXVII": 37,
        "XXXVIII": 38,
        "XXXIX": 39,
        "XL": 40,
        "XLI": 41,
        "XLII": 42,
        "XLIII": 43,
        "XLIV": 44,
        "XLV": 45,
        "XLVI": 46,
        "XLVII": 47,
        "XLVIII": 48,
        "XLIX": 49,
        "L": 50,
    }
    return roman_map.get(num_str.upper())


class CommentaryModule(AccordanceModule):
    """Parser for .atool type 5 commentary modules.

    Auto-detects commentary format and builds a scan-based verse-reference index.
    Supports multiple commentary formats:
      - Hermeneia: \\r\\r\\r\\r\\r title pages, NT only
      - JFB: \\r\\r\\r book boundaries with "THE ... BOOK ..." titles, full Bible
    """

    _SKIP_PATTERNS = _SKIP_PATTERNS

    _NONENG_PATTERNS = _NONENG_PATTERNS

    _NOTES_WHITELIST = _NOTES_WHITELIST

    _JFB_TITLE_WHITELIST = {"ASV Notes", "GNT Notes", "CEB Notes", "CEB2 Notes"}

    # Modules known to contain only Spanish/Latin-1 text (no Greek/Hebrew).
    # Skipping Greek span detection prevents MacRoman punctuation bytes
    # (e.g. 0xD0 en-dash) from triggering false Greek spans.
    _NO_GREEK_ABBREVS = frozenset({"NBLH Notes", "LBLA Notes", "VDC2014 Note"})

    _MAC_ROMAN_MAP = {
        # 0x80-0x9F: Accented letters (control chars in latin-1, real letters in Mac Roman)
        0x80: "\u00c4",  # Ä
        0x81: "\u00c5",  # Å
        0x82: "\u00c7",  # Ç
        0x83: "\u00c9",  # É
        0x84: "\u00d1",  # Ñ
        0x85: "\u00d6",  # Ö
        0x86: "\u00dc",  # Ü
        0x87: "\u00e1",  # á
        0x88: "\u00e0",  # à
        0x89: "\u00e2",  # â
        0x8A: "\u00e4",  # ä
        0x8B: "\u00e3",  # ã
        0x8C: "\u00e5",  # å
        0x8D: "\u00e7",  # ç
        0x8E: "\u00e9",  # é
        0x8F: "\u00e8",  # è
        0x90: "\u00ea",  # ê
        0x91: "\u00eb",  # ë
        0x92: "\u00ed",  # í
        0x93: "\u00ec",  # ì
        0x94: "\u00ee",  # î
        0x95: "\u00ef",  # ï
        0x96: "\u00f1",  # ñ
        0x97: "\u00f3",  # ó
        0x98: "\u00f2",  # ò
        0x99: "\u00f4",  # ô
        0x9A: "\u00f6",  # ö
        0x9B: "\u00f5",  # õ
        0x9C: "\u00fa",  # ú
        0x9D: "\u00f9",  # ù
        0x9E: "\u00fb",  # û
        0x9F: "\u00fc",  # ü
        # 0xA0-0xFF: Additional Mac Roman typographic / symbol chars
        0xAA: "\u2122",  # ™ (trademark)
        # 0xC0-0xFF: Typographic chars where Mac Roman differs from latin-1
        0xC6: "\u00c6",  # Æ
        0xC9: "\u2026",  # ellipsis
        0xD0: "\u2013",  # en dash
        0xD1: "\u2014",  # em dash
        0xD2: "\u201c",  # left double quote
        0xD3: "\u201d",  # right double quote
        0xD4: "\u2018",  # left single quote
        0xD5: "\u2019",  # right single quote
        0xD6: "\u00d6",  # Ö
        0xD7: "\u2021",  # ‡ (double dagger)
        0xDB: "\u2026",  # ellipsis
        0xE0: "\u2020",  # dagger
        0xE5: "\u2022",  # bullet
        0xE6: "\u00b6",  # pilcrow
        0xFA: "\u00fa",  # ú
    }

    # Class-level Metzger verse index cache (shared across instances)
    _metzger_index = None
    _metzger_index_loaded = False

    def __init__(self, filepath: str | Path):
        super().__init__(filepath)
        self._entries = []  # list of (book, chapter, vs_start, vs_end, offset)
        self._book_boundaries = {}  # book -> (start_offset, end_offset)
        self._volume_starts = []  # [(offset, book_name), ...]
        self._format = None  # "hermeneia" or "jfb"
        self._skipped = False  # set if module is unsupported
        self._build_index()

    @classmethod
    def _load_metzger_index(cls):
        """Load the Metzger verse→section index from JSON (cached once per process)."""
        if cls._metzger_index_loaded:
            return cls._metzger_index
        cls._metzger_index_loaded = True
        index_path = Path(__file__).parent.parent / "data" / "metzger_verse_index.json"
        if index_path.exists():
            try:
                with open(index_path, "r", encoding="utf-8") as f:
                    cls._metzger_index = json.load(f)
            except (json.JSONDecodeError, OSError):
                cls._metzger_index = {}
        else:
            cls._metzger_index = {}
        return cls._metzger_index

    # ------------------------------------------------------------------
    # Format detection
    # ------------------------------------------------------------------

    def _should_skip(self) -> bool:
        """Return True if this module's format is known unsupported."""
        abbr = self.abbrev
        for pat in self._SKIP_PATTERNS:
            if pat.lower() in abbr.lower():
                return True
        for pat in self._NONENG_PATTERNS:
            if pat.lower() in abbr.lower():
                return True
        if " Note" in abbr and abbr not in self._NOTES_WHITELIST:
            if not any(abbr.startswith(p) for p in ["Barnes", "CNT"]):
                return True
        return False

    def _detect_format(self) -> str:
        """Detect the commentary format by scanning for known patterns."""
        # Treasury of Scripture Knowledge uses magic 0x36 binary format,
        # but so do some non-Treasury modules (Gospels Lightfoot).
        # Verify title contains "Treasury" before committing to this format.
        if self.magic == 0x36 and "treasury" in self.title.lower():
            return "treasury"

        # JPS Torah Commentary uses magic 0x18 binary format
        if self.magic == 0x18:
            return "jps"

        if self.abbrev in self._JFB_TITLE_WHITELIST:
            return "jfb"

        has_hermeneia_marker = self.mm.find(b"\r\r\r\r\r", 0) != -1
        title_has_hermeneia = "hermeneia" in self.title.lower()
        if title_has_hermeneia:
            return "hermeneia"

        # Check for JA NT-2 format: \r\t entries with dot-notation verse refs
        cr_tab_count = 0
        dot_ref_count = 0
        pos = 0
        while pos < min(self.size, 0x800000):
            found = self.mm.find(b"\r\t", pos)
            if found == -1:
                break
            after = self.mm[found + 2 : found + 80]
            try:
                text = after.decode("latin-1", errors="replace")
            except Exception:
                pos = found + 2
                continue
            cr_tab_count += 1
            # Dot-notation: "\t1.1:" or "\t.1:" or "\t1.1\xd042:"
            if re.match(r"^\d+\.\d+", text) or re.match(r"^\.\d+", text):
                dot_ref_count += 1
            pos = found + 2
            if cr_tab_count > 5000:
                break

        if cr_tab_count >= 100 and dot_ref_count >= 10:
            return "ja_nt2"

        pos = 0
        count_jfb = 0
        total_rrr = 0
        while count_jfb < 3 and pos < min(self.size, 0x2000000):
            rrr = self.mm.find(b"\r\r\r", pos)
            if rrr == -1:
                break
            total_rrr += 1
            after = self.mm[rrr + 3 : rrr + 100]
            try:
                text = after.decode("latin-1", errors="replace")
            except Exception:
                pos = rrr + 3
                continue
            # Only check the first line after \r\r\r for JFB patterns.
            # Real JFB book-boundary headings are single-line titles
            # ("THE FIRST BOOK OF MOSES CALLED GENESIS").  Incidental
            # keyword matches in subsequent prose lines (e.g. "the story
            # of the Gospel" in a multi-line body paragraph) falsely
            # trigger JFB detection for type-6 General Tools.
            first_line = text.split("\r")[0].strip()
            if len(first_line) > 60:
                pos = rrr + 3
                continue
            upper = first_line.upper()
            # Skip non-title patterns common in type-6 General Tool
            # headings: lesson titles, compound topics, numbered items.
            if any(
                upper.startswith(p)
                for p in [
                    "LESSON",
                    "PART ",
                    "CHAPTER ",
                    "A ",
                    "AN ",
                ]
            ):
                pos = rrr + 3
                continue
            if " AND " in upper or " & " in upper:
                pos = rrr + 3
                continue
            if any(
                phrase in upper
                for phrase in [
                    "BOOK OF",
                    "BOOKS OF",
                    "GOSPEL",
                    "EPISTLE",
                    "THE FIRST",
                    "THE SECOND",
                    "THE THIRD",
                    "THE FOURTH",
                    "THE FIFTH",
                    "LIBRO",
                    "LIBROS",
                    "EVANGELIO",
                    "EPISTOLA",
                    "PRIMERO",
                    "SEGUNDO",
                    "TERCERO",
                    "CUARTO",
                    "QUINTO",
                ]
            ):
                # Secondary guard: require the heading to resolve to a
                # Bible book (via _detect_book_from_title).  This filters
                # out formal-looking section headings in type-6 General
                # Tools (e.g. Halper-Post Biblical's "EPISTLE PROHIBITING
                # ANYONE..." or "1. THE BRINGING OF THE FIRST-FRUITS...").
                if self._detect_book_from_title(first_line) is not None:
                    count_jfb += 1
            pos = rrr + 3

        # If we found JFB patterns but very few \r\r\r markers total,
        # continue scanning to get a better density estimate (up to 100).
        if count_jfb >= 2 and total_rrr < 20:
            extra = 0
            while total_rrr + extra < 100 and pos < min(self.size, 0x2000000):
                rrr = self.mm.find(b"\r\r\r", pos)
                if rrr == -1:
                    break
                extra += 1
                pos = rrr + 3
            total_rrr += extra

        if count_jfb >= 2:
            if has_hermeneia_marker and title_has_hermeneia:
                return "hermeneia"
            # Type-6 General Tools (e.g. JPS Haggadah) can match JFB
            # title patterns coincidentally.  Only fall through to
            # generic detection when the module has very few \r\r\r
            # markers total (suggesting a Type-6 tool with coincidental
            # matches, not a real JFB module with hundreds of triple-CR
            # book boundaries).
            if total_rrr < 20 and self._detect_generic():
                return "generic"
            return "jfb"

        if has_hermeneia_marker or title_has_hermeneia:
            # Modules like JPS Haggadah / People & Places are type-6
            # General Tools that coincidentally contain 5-CR byte patterns
            # but have no verse-referenced section headers.  Real Hermeneia
            # modules have >10 % verse-patterned entries and will fail the
            # generic check.  Check generic first so these tools aren't
            # misdetected as Hermeneia.
            if self._detect_generic():
                return "generic"
            return "hermeneia"

        # Generic format detection: modules with many \r\r markers
        # but few/no verse-reference patterns (type-6 General Tools)
        if self._detect_generic():
            return "generic"

        return "hermeneia"

    def _detect_generic(self) -> bool:
        """Return True if this module should use generic (numbered section) indexing.

        A module qualifies as generic if:
        1. It has >= 50 \\r\\r markers (significant content)
        2. A sample of \\r\\r entries shows < 10% verse-reference patterns
        """
        # Count total \r\r markers in first 8 MB (or entire file if smaller)
        scan_limit = min(self.size, 0x800000)
        rr_count = 0
        pos = 0x2000  # skip header
        while pos < scan_limit:
            found = self.mm.find(b"\r\r", pos)
            if found == -1:
                break
            rr_count += 1
            pos = found + 2

        if rr_count < 50:
            return False

        # Sample up to 100 \r\r entries to check for verse-reference patterns
        verse_count = 0
        sample_count = 0
        pos = 0x2000
        while sample_count < 100 and pos < scan_limit:
            found = self.mm.find(b"\r\r", pos)
            if found == -1:
                break
            after_start = found + 2
            # Skip font encoding table entries (very high \r density —
            # glyph-encoding tables use \r every 4-8 bytes internally).
            body_preview = self.mm[after_start : min(self.size, after_start + 200)]
            cr_count = sum(1 for b in bytes(body_preview) if b == 0x0D)
            if cr_count > 15:
                pos = found + 2
                continue
            after = self.mm[after_start : min(self.size, after_start + 200)]
            try:
                text = after.decode("latin-1", errors="replace")
                first_line = text.split("\r")[0].strip()
            except Exception:
                pos = found + 2
                continue

            # Check for verse reference patterns: "Book 1:1", "1:1", "CHAPTER", etc.
            has_verse = bool(
                re.search(r"\b\d+:\d+\b", first_line)
                or re.match(r"CHAPTER\s+\S+", first_line, re.IGNORECASE)
                or re.search(r"[A-Z][a-z]+\s+\d+:\d+", first_line)
                or re.match(r"^\d+\.\d+", first_line)
                or re.match(r"^\d+[\.\)]\s", first_line)
                or re.match(r"[Vv]erse\s+\d+", first_line)
            )
            if has_verse:
                verse_count += 1

            sample_count += 1
            pos = found + 2

        # If < 10% of entries have verse patterns, treat as generic
        return sample_count >= 10 and verse_count < sample_count * 0.10

    def _detect_hermeneia_via_inline_refs(self) -> bool:
        """Detect Hermeneia format by probing whether the module
        has enough Hermeneia signals and scanner can produce verse entries.

        Strategy:
        1. Check for Hermeneia indicators (title, abbrev, or 5-CR marker)
        2. Try the standard Hermeneia path (canonical books only)
        3. If that fails, try the broad path (non-canonical books allowed)
        4. Return True if any verse-referenced entries are produced

        State is fully saved/restored so this is side-effect-free.
        """
        # Quick check: must have at least one Hermeneia signal
        has_marker = self.mm.find(b"\r\r\r\r\r", 0) != -1
        title_has = "hermeneia" in self.title.lower()
        abbrev_has = "hermeneia" in self.abbrev.lower()
        if not (has_marker or title_has or abbrev_has):
            return False

        # Save original state
        saved_entries = list(self._entries)
        saved_books = dict(self._book_boundaries)
        saved_volumes = list(self._volume_starts)
        saved_format = self._format

        try:
            # Prong 1: Standard Hermeneia path
            self._entries = []
            self._book_boundaries = {}
            self._volume_starts = []

            self._find_book_boundaries_hermeneia()
            if self._volume_starts:
                self._scan_section_headers_hermeneia()
                verse_entries = sum(1 for e in self._entries if e[1] > 0)
                total = len(self._entries) if self._entries else 1
                is_real_hermeneia = "hermeneia" in self.title.lower()
                if verse_entries >= 10 and (
                    verse_entries >= total * 0.20
                    or (is_real_hermeneia and len(self._volume_starts) >= 3)
                ):
                    return True

            # Prong 2: Broad path (non-canonical books)
            self._entries = []
            self._book_boundaries = {}
            self._volume_starts = []

            self._find_book_boundaries_from_inline_refs()
            if self._volume_starts:
                self._scan_hermeneia_broad()
                verse_entries = sum(1 for e in self._entries if e[1] > 0)
                total = len(self._entries) if self._entries else 1
                is_real_hermeneia = "hermeneia" in self.title.lower()
                return verse_entries >= 10 and (
                    verse_entries >= total * 0.20
                    or (is_real_hermeneia and len(self._volume_starts) >= 3)
                )

            return False

        finally:
            # Restore original state
            self._entries = saved_entries
            self._book_boundaries = saved_books
            self._volume_starts = saved_volumes
            self._format = saved_format

    def _rebuild_hermeneia(self):
        """Rebuild index as Hermeneia format after inline-ref detection.

        Two-pronged approach:
        1. Try _find_book_boundaries_hermeneia() + _scan_section_headers_hermeneia()
           (works for OT2 and other canonical-book modules)
        2. If that produces 0 entries, fall back to a broader scan that accepts
           non-canonical book names (works for AF/Q/2PS with Enoch, Shep, etc.)
        """
        # Clear cached values from any prior generic-format build
        self._rr_count = 0
        self._rt_count = 0
        self._entries = []
        self._book_boundaries = {}
        self._volume_starts = []

        is_title_herm = "hermeneia" in self.title.lower()
        is_non_bible_herm = self._is_non_bible_hermeneia()

        # Prong 1: Use the standard Hermeneia detection + scan
        self._find_book_boundaries_hermeneia()
        if self._volume_starts:
            # For non-Bible Hermeneia modules (AF, Q, 2PS), use the broad
            # scan that accepts non-canonical book names and dot-notation
            # section refs.  The standard scan only recognizes Bible books.
            if is_non_bible_herm:
                self._scan_hermeneia_broad()
            else:
                self._scan_section_headers_hermeneia()

        # If standard path produced entries, we're done
        verse_count = sum(1 for e in self._entries if e[1] > 0)
        total = len(self._entries) if self._entries else 1
        # Accept hermeneia if (a) >=30% of entries have verse refs, OR
        # (b) the title says "hermeneia" AND has >=3 volume boundaries
        #     (or >=1 for non-Bible Hermeneia modules like AF/Q/2PS).
        if is_non_bible_herm:
            min_volumes = 1
        else:
            min_volumes = 3
        if verse_count >= 10 and (
            verse_count >= total * 0.30
            or (is_title_herm and len(self._volume_starts) >= min_volumes)
        ):
            self._format = "hermeneia"
            return

        # Prong 2: Broader scan for non-canonical Hermeneia modules (AF, Q, 2PS).
        # These modules have regular \r\r-delimited section headers with
        # inline verse refs but use non-canonical book names.
        self._entries = []
        self._book_boundaries = {}
        self._volume_starts = []

        self._find_book_boundaries_from_inline_refs()
        if not self._volume_starts:
            self._format = "generic"
            return

        self._scan_hermeneia_broad()
        verse_count = sum(1 for e in self._entries if e[1] > 0)
        total = len(self._entries) if self._entries else 1
        # Same quality threshold as prong 1: accept if (a) >=30% of entries
        # have verse refs OR (b) title says "hermeneia" AND has >=1 volume
        # boundary (for non-Bible Hermeneia) / >=3 (otherwise).
        is_title_herm2 = "hermeneia" in self.title.lower()
        is_non_bible2 = self._is_non_bible_hermeneia()
        min_vols2 = 1 if is_non_bible2 else 3
        if verse_count >= 10 and (
            verse_count >= total * 0.30
            or (is_title_herm2 and len(self._volume_starts) >= min_vols2)
        ):
            self._format = "hermeneia"
        else:
            self._format = "generic"

        # Apply post-scan chapter correction
        chapter_marker_count = sum(1 for e in self._entries if e[1] == 0)
        self._correct_chapters_post_scan(chapter_marker_count)

    @staticmethod
    def _is_non_bible_hermeneia() -> bool:
        """Return True if this is a non-Bible Hermeneia module (AF, Q, 2PS, AT).

        These modules cover non-canonical works (Apostolic Fathers, Q source,
        Pseudepigrapha, Apocryphal texts) rather than Bible books.
        """
        # Check via instance's title/abbrev (called as static for reuse)
        # We need instance context - this is actually an instance method.
        return False  # placeholder; will be overridden below

    def _is_non_bible_hermeneia(self) -> bool:
        """Return True if this is a non-Bible Hermeneia module (AF, Q, 2PS, AT)."""
        title_lower = self.title.lower()
        abbrev_lower = self.abbrev.lower()
        # Hermeneia AF: Apostolic Fathers
        if "hermeneia" in title_lower and "apostolic fathers" in title_lower:
            return True
        if "hermeneia af" in abbrev_lower:
            return True
        if "hermeneia q" in abbrev_lower:
            return True
        if "hermeneia2 ps" in abbrev_lower:
            return True
        if "hermeneia at" in abbrev_lower:
            return True
        # Hermeneia OT2 / NT2 may contain apocryphal/deutero books
        return False

    def _scan_hermeneia_broad(self):
        """Broad Hermeneia-style scan that accepts non-canonical book
        names. Used for AF, Q, 2PS modules whose books (1 Enoch, Shep, etc.)
        aren't in the Protestant canon.

        Scan logic mirrors _scan_section_headers_hermeneia() but without
        canonical-book validation on inline references.

        For modules with sparse volume boundaries (AF, Q), starts scanning
        from the first \r\r entry (0x2000) rather than the first volume
        start, and falls back to a generic module-title book name when
        no volume covers the current entry.
        """
        # Find the starting position: use the first \r\r entry in the file
        # body, not the first volume start (which may be deep into the file
        # if no volumes were detected near the beginning).
        first_rr = self.mm.find(b"\r\r", 0x2000)
        if first_rr == -1:
            return
        pos = first_rr

        DASH = rb"[-\x96\x97\xd0]"

        paren_ref_re = re.compile(rb"\((\d+):(\d+)(?:\s*" + DASH + rb"\s*(\d+))?\)")
        bare_ref_re = re.compile(rb"^(\d+):(\d+)(?:\s*" + DASH + rb"\s*(\d+))?[\t ;]")
        # Broad inline ref: accepts ANY book name (not just canonical ones)
        inline_ref_re_broad = re.compile(
            rb"([A-Z][a-zA-Z]*(?:\s+[A-Z][a-zA-Z]*)*)\s+(\d+):(\d+)"
            rb"(?:\s*" + DASH + rb"\s*(\d+))?"
        )
        # Dot-notation section ref: e.g. "1.1", "5.12", "2.3.1"
        dot_ref_re = re.compile(rb"^(\d+)\.(\d+)(?:\.(\d+))?")

        # Fallback book name: module title (used when no volume covers entry)
        fallback_book = _normalize_book(self.title.strip() or self.abbrev)

        current_book: str | None = None
        current_chapter = 1

        while pos < self.size:
            # Update current_book from volume starts
            for vol_off, vol_book in self._volume_starts:
                if pos < vol_off:
                    break
                current_book = vol_book

            # If no volume covers this position (entries before first volume
            # or between sparsely-detected volumes), use the fallback book
            # name derived from the module title.  This prevents entries
            # from being silently skipped.
            if current_book is None:
                current_book = fallback_book

            pos = self.mm.find(b"\r\r", pos)
            if pos == -1:
                break

            header_end = min(self.size, pos + 256)
            header = self.mm[pos + 2 : header_end]

            if header[:1] in (b"\t", b"\r"):
                pos += 2
                continue
            if header[:1] == b"[" and not header[:5].startswith(b"[vol."):
                pos += 2
                continue

            header_text = header.decode("latin-1", errors="replace")
            first_line = header_text.split("\r")[0].strip()

            # Skip entries whose first line is mostly binary/non-printable
            printable = sum(1 for c in first_line if c.isprintable() or c in "\t ")
            if len(first_line) > 0 and printable < len(first_line) * 0.3:
                pos += 2
                continue

            chapter = None
            verse_start = None
            verse_end = None

            # Ü-prefixed section headers
            if header[:1] == b"\xdc":
                after_udc = header[1:60]
                udc_chv = re.match(rb"\s*(\d+):(\d+)", after_udc)
                if udc_chv:
                    chapter = int(udc_chv.group(1))
                    verse_start = int(udc_chv.group(2))
                    verse_end = verse_start
                else:
                    udc_verse = re.match(rb"\s*(\d+)\s", after_udc)
                    if udc_verse:
                        verse_start = int(udc_verse.group(1))
                        paren = paren_ref_re.search(header[:200])
                        if paren:
                            chapter = int(paren.group(1))
                            verse_end = (
                                int(paren.group(3)) if paren.group(3) else verse_start
                            )
                        else:
                            chapter = current_chapter
                            verse_end = verse_start

            if chapter is None:
                match = bare_ref_re.search(header[:150])
                if match:
                    chapter = int(match.group(1))
                    verse_start = int(match.group(2))
                    verse_end = int(match.group(3)) if match.group(3) else verse_start

            if chapter is None:
                match = paren_ref_re.search(header[:200])
                if match and self._looks_like_section_header_broad(first_line):
                    chapter = int(match.group(1))
                    verse_start = int(match.group(2))
                    verse_end = int(match.group(3)) if match.group(3) else verse_start

            if chapter is None and self._looks_like_section_header_broad(first_line):
                match = inline_ref_re_broad.search(header[:150])
                if match:
                    book_name = match.group(1)
                    # Only accept inline refs that name the current book or
                    # a closely related work.  Skip unrelated Bible book refs
                    # in the body text (e.g. "Matt 18:19" within Ignatius
                    # commentary) that would incorrectly change the chapter.
                    resolved = _normalize_book(
                        book_name.decode("latin-1", errors="replace")
                    )
                    if resolved == current_book or current_book == fallback_book:
                        chapter = int(match.group(2))
                        verse_start = int(match.group(3))
                        verse_end = (
                            int(match.group(4)) if match.group(4) else verse_start
                        )

            # Dot-notation section ref: extract as chapter.verse from
            # entries like "1.1", "5.12", "2.3.1".  Used by Hermeneia AF
            # where sections are numbered within the volume.
            if chapter is None:
                dot_match = dot_ref_re.match(header[:40])
                if dot_match:
                    chapter = int(dot_match.group(1))
                    verse_start = int(dot_match.group(2))
                    if dot_match.group(3):
                        verse_end = int(dot_match.group(3))
                    else:
                        verse_end = verse_start
                    if not (1 <= chapter <= 150 and 1 <= verse_start <= 500):
                        chapter = None  # reject out-of-range
                        verse_start = None

            if chapter is None:
                if self._looks_like_section_header_broad(first_line):
                    chapter = 0
                    verse_start = 0
                    verse_end = 0

            if chapter is not None:
                if chapter == 0:
                    self._entries.append((current_book, 0, 0, 0, pos))
                else:
                    is_valid = (
                        1 <= chapter <= 150
                        and verse_start is not None
                        and 1 <= verse_start <= 500
                    )
                    if is_valid:
                        self._entries.append(
                            (current_book, chapter, verse_start, verse_end, pos)
                        )
                        current_chapter = chapter

            pos += 2

    @staticmethod
    def _looks_like_section_header_broad(text: str) -> bool:
        """Broader section header detection that accepts non-canonical
        book names and additional patterns found in AF/Q/2PS modules."""
        if not text:
            return False
        if text.startswith("\xdc"):
            return True
        if text and text[0].isdigit():
            rest = text[1:]
            if rest and rest[0] in ". -":
                return True
            if (
                len(text) >= 3
                and text[1] == " "
                and (text[2].isupper() or not text[2].isalpha())
            ):
                return True
            return False
        if len(text) >= 2 and (text[0].isupper() or text[0] in "\xca\xa5"):
            return True
        for prefix in [
            "Excursus",
            "Analysis",
            "Interpretation",
            "Summary",
            "Sources",
            "Origin",
            "Literature",
            "History",
            "Structure",
            "Commentary",
            "Foreword",
            "Preface",
            "Introduction",
            "Conclusion",
            "Author",
            "Title",
            "Bibliography",
            "Sigla",
            "Abbrev",
            "Signs",
            "Editions",
        ]:
            if text.startswith(prefix):
                return True
        return False

    def _find_book_boundaries_from_inline_refs(self):
        """Build book boundaries by sampling \\r\\r entries and
        inferring book starts from the first occurrence of each book (canonical
        or non-canonical). Used as fallback when \\r\\r\\r\\r\\r markers are absent
        or don't contain recognizable canonical book titles.
        """
        book_first_seen = {}  # book -> (offset, first_chapter)
        pos = 0x2000
        scan_limit = min(self.size, 0x800000)

        # Books found via \r\r\r markers (JFB-style volume boundaries)
        rrr_pos = 0
        while rrr_pos < scan_limit:
            rrr = self.mm.find(b"\r\r\r", rrr_pos)
            if rrr == -1:
                break
            after = self.mm[rrr + 3 : min(self.size, rrr + 120)]
            try:
                title_text = after.decode("latin-1", errors="replace")
                first_line = title_text.split("\r")[0].strip()
            except Exception:
                rrr_pos = rrr + 3
                continue
            if len(first_line) > 60:
                rrr_pos = rrr + 3
                continue
            book = self._detect_book_from_title(first_line)
            if book is None and first_line:
                # Try normalizing non-canonical book names
                book = _normalize_book(first_line)
            if book and book not in book_first_seen and self._is_valid_book_name(book):
                # Filter: only accept \r\r\r markers whose title text looks
                # like a real volume title page (contains "Commentary",
                # "Hermeneia", or a known Hermeneia AF work keyword).
                # This prevents incidental \r\r\r markers in body text
                # (e.g. "Footnotes", "Abbreviations", author bios) from
                # becoming spurious volume boundaries.
                upper_title = first_line.upper()
                looks_like_volume = (
                    "COMMENTARY" in upper_title
                    or "HERMENEIA" in upper_title
                    or self._has_hermeneia_af_keyword(first_line)
                )
                if looks_like_volume:
                    book_first_seen[book] = (rrr, 1)
            rrr_pos = rrr + 3

            # Also scan \r\r entries for inline refs with new book names.
        # Guard: only accept inline refs from entries whose first line looks
        # like a section header (starts with digit, uppercase letter, or Ü),
        # NOT from prose body text where a verse ref like "Matt 18:19" is
        # merely part of the commentary content.
        while pos < scan_limit:
            found = self.mm.find(b"\r\r", pos)
            if found == -1:
                break
            after_start = found + 2
            after = self.mm[after_start : min(self.size, after_start + 200)]

            if after[:1] in (b"\t", b"["):
                pos = found + 2
                continue

            # Only consider inline refs from section-header-looking entries.
            # A section header starts with: digit (1.1, 5.3), Ü, or an
            # uppercase letter followed by a space (proper name heading).
            # Skip entries where the first line is longer than 120 chars
            # (prose body text, not a header).
            first_line_end = after.find(b"\r")
            first_line = after[:first_line_end] if first_line_end != -1 else after[:120]
            first_line_stripped = first_line.lstrip(b" \t")
            is_section_header = False
            if first_line_stripped[:1] == b"\xdc":
                is_section_header = True
            elif first_line_stripped[:1].isdigit():
                is_section_header = True
            elif (
                len(first_line_stripped) >= 2
                and first_line_stripped[:1].isalpha()
                and first_line_stripped[1:2] in (b" ", b".")
            ):
                # Capital letter + space/dot = likely heading
                if len(first_line_stripped) < 120:
                    is_section_header = True

            if is_section_header:
                # Look for inline "Book ch:v" reference (broad: any book name)
                inline_match = re.search(
                    rb"([1-4]?\s*[A-Z][a-zA-Z]*(?:\s+[A-Z][a-zA-Z]*)*)\s+(\d+):(\d+)",
                    first_line[:150],
                )
                if inline_match:
                    book_name = inline_match.group(1).decode(
                        "latin-1", errors="replace"
                    )
                    book = _normalize_book(book_name)
                    if (
                        book
                        and book not in book_first_seen
                        and self._is_valid_book_name(book)
                    ):
                        chapter = int(inline_match.group(2))
                        book_first_seen[book] = (found, chapter)

            pos = found + 2
            if len(book_first_seen) >= 66:  # full Bible
                break

        # Build volume starts sorted by offset
        volumes = [(off, book) for book, (off, _) in book_first_seen.items()]
        volumes.sort(key=lambda x: x[0])
        self._volume_starts = volumes

        # Build book boundaries
        for i, (off, book) in enumerate(volumes):
            if i + 1 < len(volumes):
                end = volumes[i + 1][0]
            else:
                end = self.size
            if book not in self._book_boundaries:
                self._book_boundaries[book] = (off, end)
            else:
                old_start, _ = self._book_boundaries[book]
                self._book_boundaries[book] = (old_start, end)

    def _count_rr_markers(self) -> int:
        """Count \\r\\r markers starting after the header region (0x2000)."""
        count = 0
        pos = 0x2000
        scan_limit = min(self.size, 0x800000)
        while pos < scan_limit:
            found = self.mm.find(b"\r\r", pos)
            if found == -1:
                break
            if found < pos:  # safety: mmap.find must return >= start
                break
            count += 1
            pos = found + 2
        return count

    # ------------------------------------------------------------------
    # Apparatus detection and JSON-backed loading
    # ------------------------------------------------------------------

    @staticmethod
    def _is_apparatus_module_name(name: str) -> bool:
        """Return True if the name looks like an apparatus module."""
        return "apparatus" in name.lower()

    def _is_apparatus(self) -> bool:
        """Check if this module is an apparatus module (NA28/LXX/MT-ETCBC)."""
        abbr = (self.abbrev or "").lower()
        title = (self.title or "").lower()
        return self._is_apparatus_module_name(abbr) or self._is_apparatus_module_name(
            title
        )

    def _build_index_apparatus(self):
        """Build verse-referenced index from pre-extracted apparatus JSON.

        Apparatus modules (NA28, LXX, LXX2, MT-ETCBC-A) use Vector B
        encoding (``decode_apparatus``) and have their text pre-extracted
        into ``data/*_apparatus_text.json`` files plus matching
        ``*_verse_index.json`` verse→entry maps.

        We load both JSON files so ``get_section()`` can do verse-reference
        lookups with clean Unicode output.
        """
        import json as _json
        from pathlib import Path as _Path

        # Map module abbreviation to JSON basename
        abbr_lower = (self.abbrev or "").lower().replace(" apparatus", "")
        name_map = {
            "na28": ("NA28_apparatus_text.json", "NA28_verse_index.json"),
            "lxx": ("LXX_apparatus_text.json", "LXX_verse_index.json"),
            "lxx2": ("LXX2_apparatus_text.json", "LXX2_verse_index.json"),
            "mt-etcbc-a": ("MT-ETCBC_apparatus_text.json", "MT-ETCBC_verse_index.json"),
        }
        pair = name_map.get(abbr_lower)
        if pair is None:
            self._skipped = True
            self._format = "unsupported"
            return

        text_file, verse_file = pair
        data_dir = _Path(__file__).parent.parent / "data"
        text_path = data_dir / text_file
        verse_path = data_dir / verse_file

        if not text_path.exists():
            self._skipped = True
            self._format = "unsupported"
            return

        with open(text_path, "r", encoding="utf-8") as f:
            self._apparatus_data = _json.load(f)

        if verse_path.exists():
            with open(verse_path, "r", encoding="utf-8") as f:
                self._apparatus_verse_index = _json.load(f)
        else:
            self._apparatus_verse_index = {}

        self._format = "apparatus"

        # Build _book_boundaries from verse-index keys so list_books()
        # and browse_commentary_chapter() work.
        books_seen = set()
        for key in self._apparatus_verse_index:
            # Key format: "Matthew 1:1"
            parts = key.split(" ")
            if parts:
                book_name = parts[0]
                if book_name not in books_seen:
                    books_seen.add(book_name)
                    self._book_boundaries[book_name] = (0, 0)

        # Build verse-referenced entries for get_section() lookup.
        # Each entry: (book, chapter, vs_start, vs_end, idx)
        # where idx is the JSON entry index for lookup.
        from .canon import (
            parse_reference as _parse_ref,
            osis_to_spaceless,
            BOOKS_BY_OSIS,
        )

        for key, indices in self._apparatus_verse_index.items():
            try:
                result = _parse_ref(key)
                if result and indices:
                    book_obj = BOOKS_BY_OSIS.get(result.book_osis)
                    if book_obj:
                        book = osis_to_spaceless(book_obj.osis)
                        ch = result.chapter
                        vs = result.verse
                        self._entries.append((book, ch, vs, vs, indices[0]))
            except (ValueError, AttributeError):
                pass

    def _get_apparatus_section(self, reference: str) -> str:
        """Look up and decode apparatus text for a Bible verse reference.

        Converts the reference to a verse-index key (e.g. "Matthew 1:1"),
        looks up matching JSON entry indices, fetches raw_text, and
        decodes it via ``decode_apparatus()`` for clean Unicode output
        with Greek text and critical-apparatus sigla (ℵ, 𝔐, ℓ, etc.).
        """
        from .canon import (
            BOOKS_BY_OSIS,
        )

        try:
            book, chapter, verse = parse_reference(reference)
        except ValueError:
            return f"[Invalid reference: {reference}]"

        # Convert spaceless book to human-readable name for verse-index key
        book_osis = spaceless_to_osis(book)
        book_obj = BOOKS_BY_OSIS.get(book_osis)
        book_name = book_obj.name if book_obj else book

        key = f"{book_name} {chapter}:{verse}"
        entry_indices = self._apparatus_verse_index.get(key, [])
        if not entry_indices:
            return f"[No apparatus entry found for {reference}]"

        entries = self._apparatus_data.get("entries", [])
        parts = []
        for idx in entry_indices:
            if idx >= len(entries):
                continue
            entry = entries[idx]
            raw_text = entry.get("raw_text", "")
            if not raw_text:
                continue
            try:
                raw_bytes = raw_text.encode("latin-1")
                decoded = decode_apparatus(raw_bytes)
            except (UnicodeEncodeError, Exception):
                # Fall back to pre-decoded text if available
                decoded = entry.get("decoded_text", raw_text)
            if decoded:
                parts.append(decoded)

        if not parts:
            return f"[No apparatus entry found for {reference}]"

        return "\n\n".join(parts)

    # ------------------------------------------------------------------
    # Main index builder
    # ------------------------------------------------------------------

    def _build_index(self):
        """Scan the file to build the verse-reference index."""
        if self._should_skip():
            self._format = "unsupported"
            self._skipped = True
            return

        # Apparatus modules (NA28, LXX, LXX2, MT-ETCBC-A) use Vector B
        # encoding and are pre-extracted into JSON.  Route to JSON loader.
        if self._is_apparatus():
            self._build_index_apparatus()
            return

        self._format = self._detect_format()

        if self._format == "generic":
            self._build_index_generic()
        elif self._format == "jps":
            self._build_jps_index()
        elif self._format == "treasury":
            self._build_treasury_index()
        elif self._format == "jfb":
            self._find_book_boundaries_jfb()
            if not self._volume_starts:
                # No \r\r\r book-boundary markers found (e.g. ASV Notes).
                # Fall back to detecting books from inline verse references
                # like "Genesis 1:2" in \r\r entries.
                self._find_book_boundaries_from_inline_refs()
                self._from_inline_refs = True
            chapter_marker_count = self._scan_section_headers_jfb()
            self._correct_chapters_post_scan(chapter_marker_count)
        elif self._format == "ja_nt2":
            self._find_book_boundaries_ja_nt2()
            self._scan_section_headers_ja_nt2()
        else:
            self._find_book_boundaries_hermeneia()
            self._scan_section_headers_hermeneia()

        # Quality check for Hermeneia: if most entries lack verse
        # references (chapter=0), the module is likely a type-6 General
        # Tool misdetected as Hermeneia (e.g. BiblicalTraining.org, which
        # has 5-CR markers but its content isn't verse-referenced).
        # Real Hermeneia commentaries have verse refs in ≥30% of entries,
        # OR have both "hermeneia" in the title AND ≥3 volume boundaries
        # (real multi-book Hermeneia modules like NT-21 and OT-19 have
        # high section-header ratios). Non-title-hermeneia modules with
        # volume boundaries but low verse-ref density (e.g.
        # BiblicalTraining.org) should remain generic.
        if self._format == "hermeneia":
            verse_count = sum(1 for e in self._entries if e[1] > 0)
            total = len(self._entries) if self._entries else 1
            is_title_hermeneia = "hermeneia" in self.title.lower()
            # Non-Bible Hermeneia modules (AF, Q, 2PS, AT) may have a single
            # volume and proportionally more ch=0 entries (introductions,
            # bibliographies).  Lower the volume threshold from 3 to 1.
            is_non_bible = self._is_non_bible_hermeneia()
            min_volumes = 1 if is_non_bible else 3
            if (verse_count < 10 or verse_count < total * 0.30) and not (
                is_title_hermeneia and len(self._volume_starts) >= min_volumes
            ):
                self._entries = []
                self._book_boundaries = {}
                self._volume_starts = []
                self._format = "generic"
                self._build_index_generic()

        # Post-build correction: if detected format produced 0 entries,
        # fall back to generic so the module has usable content.
        if self._format != "generic" and len(self._entries) == 0:
            self._format = "generic"
            self._build_index_generic()

        # Post-detection fallback: if a non-generic format produced very few
        # entries but the module has many \r\r markers, rebuild as generic.
        # This catches type-6 General Tool modules that happen to match
        # jfb/hermeneia/ja_nt2 patterns in a few places but are really
        # structured as generic numbered sections.
        # Also catches reading-plan modules (low \r\r but high \r\t count).
        if self._format != "generic":
            rr_count = self._count_rr_markers()
            # Count \r\t markers for reading-plan detection
            rt_count = 0
            rpos = 0
            while rpos < min(self.size, 0x800000):
                found = self.mm.find(b"\r\t", rpos)
                if found == -1:
                    break
                rt_count += 1
                rpos = found + 2
                if rt_count > 50000:
                    break
            has_zero_entries = len(self._entries) == 0
            is_reading_plan = rt_count > 200 and rr_count < 50 and has_zero_entries
            # Fallback if: we have many \r\r markers (>100) but indexed
            # less than 10% of them as entries (most content is missing)
            needs_fallback = (
                is_reading_plan
                or (rr_count > 100 and len(self._entries) < rr_count * 0.10)
                or (len(self._entries) == 0 and rr_count >= 15)
            )
            if needs_fallback:
                self._entries = []
                self._book_boundaries = {}
                self._volume_starts = []
                self._format = "generic"
                self._build_index_generic()

        # Post-detection Hermeneia recovery for modules that were
        # classified as generic but contain inline "Book ch:v" verse references.
        if self._format == "generic":
            if self._detect_hermeneia_via_inline_refs():
                self._rebuild_hermeneia()

    # ==================================================================
    # JFB format
    # ==================================================================

    def _find_book_boundaries_jfb(self):
        """Find book boundaries via \\r\\r\\r + keyword-based book detection."""
        pos = 0
        volumes = []
        last_seen = {}  # book -> offset, for deduplication

        while pos < self.size:
            rrr = self.mm.find(b"\r\r\r", pos)
            if rrr == -1:
                break

            after = self.mm[rrr + 3 : rrr + 120]

            # Handle \\r\\r\\r\\r boundaries (ESV Notes style) -- skip leading \\r
            skip_bytes = 3
            if after[:1] == b"\r":
                after = self.mm[rrr + 4 : rrr + 120]
                skip_bytes = 4

            try:
                title_text = after.decode("latin-1", errors="replace")
            except Exception:
                pos = rrr + skip_bytes
                continue

            first_line = title_text.split("\r")[0].strip()

            # Heuristic: real book boundaries have short heading lines
            if len(first_line) > 60:
                pos = rrr + skip_bytes
                continue

            book = self._detect_book_from_title(first_line)

            if book:
                # Deduplicate: skip if same book within 100KB
                if book in last_seen and (rrr - last_seen[book]) < 0x19000:
                    pos = rrr + skip_bytes
                    continue
                last_seen[book] = rrr
                volumes.append((rrr, book))

            pos = rrr + skip_bytes

        # Sort by offset
        volumes.sort(key=lambda x: x[0])

        # Build book boundaries and volume starts
        self._volume_starts = volumes
        for i, (off, book) in enumerate(volumes):
            if i + 1 < len(volumes):
                end = volumes[i + 1][0]
            else:
                end = self.size
            if book not in self._book_boundaries:
                self._book_boundaries[book] = (off, end)
            else:
                old_start, _ = self._book_boundaries[book]
                self._book_boundaries[book] = (old_start, end)

    def _detect_book_from_title(self, title: str) -> str | None:
        """Detect canonical book name from a title string using keyword matching."""
        upper = title.upper()

        # Skip non-book titles
        if any(
            upper.startswith(p)
            for p in [
                "INTRODUCTION",
                "GENERAL PREFACE",
                "CLASS ",
                "COMMENTS ON",
                "COPIES ",
                "ANTEDILUVIAN",
            ]
        ):
            return None
        if upper.startswith("PREFACE") and "EPISTLE" not in upper:
            return None
        if upper.startswith("CHAPTER ") or upper.startswith("PSALM "):
            return None

        # JPS page-marker abbreviations: [Esth, p. iii], [Eccl, p. iii], etc.
        _JPS_PAGE_ABBREV = {
            "ESTH": "Esther",
            "ECCL": "Ecclesiastes",
            "SONG": "SongofSolomon",
            "RUTH": "Ruth",
            "JONAH": "Jonah",
        }
        page_match = re.match(r"\[(\w+),\s*p\.", title)
        if page_match:
            abbr = page_match.group(1).upper()
            if abbr in _JPS_PAGE_ABBREV:
                return _JPS_PAGE_ABBREV[abbr]

        # Detect number prefix
        number = 0
        if "FIRST" in upper or "1 " in upper[:30] or upper.startswith("1 "):
            number = 1
        elif "SECOND" in upper or "2 " in upper[:30] or upper.startswith("2 "):
            number = 2
        elif "THIRD" in upper or "3 " in upper[:30] or upper.startswith("3 "):
            number = 3

        # Books that can have number prefixes
        numbered_book_keywords = [
            ("SAMUEL", "Samuel"),
            ("KINGS", "Kings"),
            ("CHRONICLES", "Chronicles"),
            ("CORINTHIANS", "Corinthians"),
            ("THESSALONIANS", "Thessalonians"),
            ("TIMOTHY", "Timothy"),
            ("PETER", "Peter"),
            ("JOHN", "John"),
        ]

        for keyword, canonical_base in numbered_book_keywords:
            if keyword in upper:
                if number > 0:
                    return f"{number}{canonical_base}"

        # Single-name books (no number prefix needed)
        single_name_map = [
            ("GENESIS", "Genesis"),
            ("EXODUS", "Exodus"),
            ("LEVITICUS", "Leviticus"),
            ("NUMBERS", "Numbers"),
            ("DEUTERONOMY", "Deuteronomy"),
            ("JOSHUA", "Joshua"),
            ("JUDGES", "Judges"),
            ("RUTH", "Ruth"),
            ("EZRA", "Ezra"),
            ("NEHEMIAH", "Nehemiah"),
            ("ESTHER", "Esther"),
            ("JOB", "Job"),
            ("PSALMS", "Psalms"),
            ("PROVERBS", "Proverbs"),
            ("ECCLESIASTES", "Ecclesiastes"),
            ("SONG OF SOLOMON", "SongofSolomon"),
            ("ISAIAH", "Isaiah"),
            ("JEREMIAH", "Jeremiah"),
            ("LAMENTATIONS", "Lamentations"),
            ("EZEKIEL", "Ezekiel"),
            ("DANIEL", "Daniel"),
            ("HOSEA", "Hosea"),
            ("JOEL", "Joel"),
            ("AMOS", "Amos"),
            ("OBADIAH", "Obadiah"),
            ("JONAH", "Jonah"),
            ("MICAH", "Micah"),
            ("NAHUM", "Nahum"),
            ("HABAKKUK", "Habakkuk"),
            ("ZEPHANIAH", "Zephaniah"),
            ("HAGGAI", "Haggai"),
            ("ZECHARIAH", "Zechariah"),
            ("MALACHI", "Malachi"),
            # Gospels
            ("MATTHEW", "Matthew"),
            ("MARK", "Mark"),
            # Luke: only if "ACTS" not also in the title
            ("LUKE", "Luke"),
            ("ACTS", "Acts"),
            ("ROMANS", "Romans"),
            ("GALATIANS", "Galatians"),
            ("EPHESIANS", "Ephesians"),
            ("PHILIPPIANS", "Philippians"),
            ("COLOSSIANS", "Colossians"),
            ("TITUS", "Titus"),
            ("PHILEMON", "Philemon"),
            ("HEBREWS", "Hebrews"),
            ("JAMES", "James"),
            ("JUDE", "Jude"),
            ("REVELATION", "Revelation"),
            ("LAODICEANS", None),  # non-canonical
        ]

        # Check single-name books
        found_book = None
        for keyword, canonical in single_name_map:
            if keyword in upper:
                if canonical is None:
                    return None  # non-canonical
                # Luke vs Acts disambiguation
                if keyword == "LUKE" and "ACTS" in upper:
                    continue  # it's Acts, the "Luke" keyword is part of the author name
                found_book = canonical
                break  # first match wins (order matters)

        if found_book:
            return found_book

        # Gospel of John (special case: GOSPEL + JOHN but no number prefix)
        if "GOSPEL" in upper and "JOHN" in upper:
            return "John"

        return None

    def _scan_section_headers_jfb(self):
        """Scan JFB-style entries for verse references.

        JFB/Clarke format: after each \\r\\r (and \\r\\r\\r for chapter markers),
        entry headers may contain:
          "Bookname ch:v[-v][, v. [v.]] TITLE"  (section header)
          "CHAPTER N" / "CHAPTER I"            (chapter marker, \\r\\r or \\r\\r\\r)
          "ch:v[-v] text"                       (bare reference, no book name)
          "Verse v." or "v. "                   (verse-level commentary)
        """
        if not self._volume_starts:
            return 0

        DASH = rb"[-\x96\x97\xd0]"
        inline_ref_re = re.compile(
            rb"^(\d*\s*[A-Z][a-z]+(?:\s[A-Z][a-z]+)*)\s+(\d+):(\d+)"
            rb"(?:\s*" + DASH + rb"\s*(\d+))?"
            rb"(?:,\s*\d+\s*\.?\s*)?"
        )
        # Bare chapter:verse entries (no book name), e.g., "1:5 Note text"
        # Used by Catholic SB-3 and other study-Bible verse annotations
        bare_chv_re = re.compile(rb"^(\d+):(\d+)(?:\s*" + DASH + rb"\s*(\d+))?")
        # ESV Notes / study-note style: verse ref anywhere in first line
        anywhere_ref_re = re.compile(
            rb"(\d*\s*[A-Z][a-z]+(?:\s[A-Z][a-z]+)*)\s+(\d+):(\d+)"
            rb"(?:\s*" + DASH + rb"\s*(\d+))?"
        )
        # Metzger-style dot-based verse refs: "N.N-N" at start of entry
        dot_ref_re = re.compile(rb"^(\d+)\.(\d+)(?:\s*" + DASH + rb"\s*(\d+))?")

        pos = self._volume_starts[0][0]
        current_book = None
        current_chapter = 1
        chapter_marker_count = 0

        while pos < self.size:
            # Single scan: find \r\r, then check if followed by another \r
            rr_at = self.mm.find(b"\r\r", pos)
            if rr_at == -1:
                break

            # Determine if this is actually \r\r\r (or \r\r\r\r)
            use_rrr = False
            if rr_at + 2 < self.size and self.mm[rr_at + 2] == 0x0D:
                # \r\r\r found — check for \r\r\r\r (4 consecutive CR)
                rr = rr_at
                use_rrr = True
                skip_bytes = 3
                if rr_at + 3 < self.size and self.mm[rr_at + 3] == 0x0D:
                    skip_bytes = 4
            else:
                rr = rr_at
                skip_bytes = 2

            # Update current book
            prev_book = current_book
            for vol_off, vol_book in self._volume_starts:
                if rr < vol_off:
                    break
                current_book = vol_book

            if current_book != prev_book:
                current_chapter = 1

            if current_book is None:
                pos = rr + skip_bytes
                continue

            after_start = rr + skip_bytes
            after = self.mm[after_start : after_start + 200]

            if use_rrr:
                # \r\r\r entry: could be a chapter marker or introduction
                ch = _parse_chapter(after[:40])
                if ch is not None:
                    # Prevent false positives from comparison tables
                    # (e.g. "Chapter 1\tChapter 3" column headers)
                    # Only check the first line for tabs, not the full buffer
                    first_r = after.find(b"\r")
                    line = after[:first_r] if first_r != -1 else after[:40]
                    if b"\t" not in line:
                        current_chapter = ch
                        chapter_marker_count += 1
                pos = rr + skip_bytes
                continue

            # \r\r entry: could be section header, verse commentary, or continuation
            if after[:1] == b"\t":
                # Tab-indented verse entries (JPS style):
                #   \r\r\tN. text  (single tab, Song of Solomon)
                #   \r\r\t\tN. text (double tab, Ruth/Esther/Ecclesiastes)
                tab_text = after.lstrip(b"\t")[:40]
                if tab_text[:1].isdigit():
                    # "N. " or "N) " verse-only (uses current_chapter)
                    verse_match = re.match(rb"(\d+)\s*[\.\)]\s", tab_text)
                    if verse_match:
                        vs = int(verse_match.group(1))
                        if 1 <= vs <= 176:
                            self._entries.append(
                                (current_book, current_chapter, vs, vs, rr)
                            )
                    else:
                        # Bare ch:v format (e.g., "1:1. text" or "1:1 text")
                        chv_match = bare_chv_re.match(tab_text)
                        if chv_match and current_book is not None:
                            chapter = int(chv_match.group(1))
                            verse_start = int(chv_match.group(2))
                            verse_end = (
                                int(chv_match.group(3))
                                if chv_match.group(3)
                                else verse_start
                            )
                            if chapter <= 150 and verse_start <= 176:
                                current_chapter = chapter
                                self._entries.append(
                                    (current_book, chapter, verse_start, verse_end, rr)
                                )
                pos = rr + skip_bytes
                continue
            if after[:2] == b"\r\r" or after[:1] == b"\r":
                pos = rr + skip_bytes
                continue

            # Check for inline verse reference (section header)
            ref_match = inline_ref_re.match(after[:150])
            if ref_match:
                book_name_bytes = ref_match.group(1)
                book_name = book_name_bytes.decode("latin-1", errors="replace")
                canonical = _normalize_book(book_name)
                if canonical in ALL_BOOKS:
                    chapter = int(ref_match.group(2))
                    verse_start = int(ref_match.group(3))
                    verse_end = (
                        int(ref_match.group(4)) if ref_match.group(4) else verse_start
                    )
                    # Only update current_chapter if the reference is for the
                    # current volume book (skip cross-references to other books
                    # that would corrupt chapter tracking for subsections).
                    if canonical == current_book and chapter <= current_chapter + 5:
                        current_chapter = chapter
                    self._entries.append(
                        (canonical, chapter, verse_start, verse_end, rr)
                    )
                    pos = rr + skip_bytes
                    continue

            # Bare chapter:verse entries (e.g., "1:5 Note text")
            # Uses current_book from volume tracking
            bare_match = bare_chv_re.match(after[:80])
            if bare_match and current_book is not None:
                chapter = int(bare_match.group(1))
                verse_start = int(bare_match.group(2))
                verse_end = (
                    int(bare_match.group(3)) if bare_match.group(3) else verse_start
                )
                if chapter <= 150 and verse_start <= 176:
                    # Detect cross-reference index entries: "N:N\\tPAGE"
                    # (e.g., "1:26\\t99"). These appear at the end of some
                    # modules (Sacra Pagina, JA NT-2) as a page-number index.
                    # Skip them entirely: they are not useful for commentary
                    # lookup, and updating current_chapter from them corrupts
                    # chapter tracking for subsequent verse entries.
                    match_end = bare_match.end()
                    if (
                        match_end < len(after)
                        and after[match_end : match_end + 1] == b"\t"
                        and after[match_end + 1 : match_end + 2].isdigit()
                    ):
                        pos = rr + skip_bytes
                        continue
                    if chapter <= current_chapter + 5:
                        current_chapter = chapter
                    self._entries.append(
                        (current_book, chapter, verse_start, verse_end, rr)
                    )
                    pos = rr + skip_bytes
                    continue

            # Metzger-style dot-based verse refs: "N.N-N" at start of entry
            dot_match = dot_ref_re.match(after[:40])
            if dot_match and current_book is not None:
                chapter = int(dot_match.group(1))
                verse_start = int(dot_match.group(2))
                verse_end = (
                    int(dot_match.group(3)) if dot_match.group(3) else verse_start
                )
                if chapter <= 150 and verse_start <= 176:
                    current_chapter = chapter
                    self._entries.append(
                        (current_book, chapter, verse_start, verse_end, rr)
                    )
                    pos = rr + skip_bytes
                    continue

            # Extract first line for remaining checks
            first_line = (
                after.decode("latin-1", errors="replace").split("\r")[0].strip()
            )

            # ESV Notes style: verse reference anywhere in the first line
            # Guard: only match if reference is in a parenthetical group (ESV-style)
            # or the line is a clear header (short, starting with capital).
            anywhere_match = anywhere_ref_re.search(
                first_line.encode("latin-1", errors="replace")[:200]
            )
            if anywhere_match and current_book is not None:
                start_pos = anywhere_match.start()
                is_esv_header = (
                    start_pos > 0
                    and start_pos < len(first_line)
                    and b"("
                    in first_line.encode("latin-1", errors="replace")[: start_pos + 60]
                ) or (len(first_line) < 120 and first_line and first_line[0].isupper())
                if is_esv_header:
                    book_name_bytes = anywhere_match.group(1)
                    book_name = book_name_bytes.decode("latin-1", errors="replace")
                    canonical = _normalize_book(book_name)
                    if canonical in ALL_BOOKS:
                        chapter = int(anywhere_match.group(2))
                        verse_start = int(anywhere_match.group(3))
                        verse_end = (
                            int(anywhere_match.group(4))
                            if anywhere_match.group(4)
                            else verse_start
                        )
                        if chapter <= 150 and verse_start <= 176:
                            # Only update current_chapter if the reference is
                            # for the current volume book (skip cross-refs).
                            if canonical == current_book:
                                current_chapter = chapter
                            self._entries.append(
                                (canonical, chapter, verse_start, verse_end, rr)
                            )
                            pos = rr + skip_bytes
                            continue

            # Check for chapter marker at \r\r level (JFB)
            ch = _parse_chapter(after[:40])
            if ch is not None:
                # Only check the first line for tabs (comparison table guard)
                first_r = after.find(b"\r")
                line = after[:first_r] if first_r != -1 else after[:40]
                if b"\t" not in line:
                    current_chapter = ch
                    chapter_marker_count += 1
                pos = rr + skip_bytes
                continue

            # Check for chapter-title verse range: "(vv. 1:1-3)" style
            _ch_range_re = re.compile(
                r"\(vv?\.?\s*(\d+)\s*:\s*(\d+)\s*" + DASH.decode() + r"\s*(\d+)\w*\)"
            )
            ch_range_match = _ch_range_re.search(first_line)
            if ch_range_match and current_book is not None:
                chapter = int(ch_range_match.group(1))
                verse_start = int(ch_range_match.group(2))
                verse_end = int(ch_range_match.group(3))
                if chapter <= 150 and verse_start <= 176:
                    current_chapter = chapter
                    self._entries.append(
                        (current_book, chapter, verse_start, verse_end, rr)
                    )
                    pos = rr + skip_bytes
                    continue

            # Check for verse commentary markers

            # "Verse N." or "Verse N " pattern (Clarke)
            verse_w_match = re.match(r"[Vv]erse\s+(\d+)", first_line)
            if verse_w_match:
                vs = int(verse_w_match.group(1))
                if 1 <= vs <= 176:
                    self._entries.append((current_book, current_chapter, vs, vs, rr))
                pos = rr + skip_bytes
                continue

            # "N. " or "N) " at start (JFB verse commentary)
            verse_match = re.match(r"(\d+)\s*[\.\)]\s", first_line)
            if verse_match:
                vs = int(verse_match.group(1))
                if 1 <= vs <= 176:
                    self._entries.append((current_book, current_chapter, vs, vs, rr))

            # Chrysostom: "Ver. N." verse-level commentary markers
            # e.g., "Ver. 2. Which He promised afore by His Prophets..."
            ver_match = re.match(r"Ver\.\s+(\d+)", first_line)
            if ver_match and current_book is not None and current_chapter > 0:
                vs = int(ver_match.group(1))
                if 1 <= vs <= 176:
                    self._entries.append((current_book, current_chapter, vs, vs, rr))
                pos = rr + skip_bytes
                continue

            # Chrysostom: abbreviated/all-caps book refs at start of \\r\\r entry
            # e.g., "ROM. 1:1, 2." or "ACTS 2:1, 2."
            _CHRYS_ABBREV = {
                b"ROM": "Romans",
                b"ACTS": "Acts",
            }
            chrys_ref_re = re.compile(
                rb"^([A-Z]{2,})\.?\s+(\d+):(\d+)"
                rb"(?:\s*[,;]\s*(\d+))?"
            )
            chrys_match = chrys_ref_re.match(after[:80])
            if chrys_match:
                abbrev = chrys_match.group(1)
                if abbrev in _CHRYS_ABBREV:
                    canonical = _CHRYS_ABBREV[abbrev]
                    chapter = int(chrys_match.group(2))
                    verse_start = int(chrys_match.group(3))
                    verse_end = (
                        int(chrys_match.group(4))
                        if chrys_match.group(4)
                        else verse_start
                    )
                    if chapter <= 150 and verse_start <= 176:
                        current_chapter = chapter
                        self._entries.append(
                            (canonical, chapter, verse_start, verse_end, rr)
                        )
                        pos = rr + skip_bytes
                        continue

            pos = rr + skip_bytes

        # Capture book introductions / prefatory matter before the first verse entry.
        # Content between a book boundary and its first indexed entry is orphaned
        # by verse-keyed scanning (e.g. JFB book intros, Barnes prefaces).
        for book, (start, end) in self._book_boundaries.items():
            book_entries = [e for e in self._entries if e[0] == book]
            if not book_entries:
                continue
            first_off = min(e[4] for e in book_entries)
            intro_start = start + 1  # skip the \r\r\r book-boundary delimiter
            if first_off - intro_start > 100:
                self._entries.append((book, 0, 0, 0, intro_start))

        return chapter_marker_count

    # ----------------------------------------------------------------
    # Post-scan chapter correction for modules without explicit markers
    # ----------------------------------------------------------------

    def _correct_chapters_post_scan(self, chapter_marker_count):
        """Fix chapter misassignments when explicit CHAPTER N markers are absent.

        Some modules (Sacra Pagina, Catholic SB-3) lack explicit chapter
        markers but share the JFB entry format. Cross-references in bare
        ch:v entries can pollute the chapter tracker, assigning later
        verse entries to wrong chapters.

        Guard: skip correction when >=2 explicit chapter markers exist,
        or when chapters come from authoritative inline references
        (e.g. ASV Notes fallback where every entry carries its own
        ``Book ch:v`` header).
        """
        if chapter_marker_count is None:
            chapter_marker_count = 0
        if chapter_marker_count >= 2:
            return
        if getattr(self, "_from_inline_refs", False):
            return

        # Group entry indices by book, preserving offset order.
        # Only include entries that fall within the book's file boundary.
        # Cross-references to this book that appear in OTHER books' sections
        # have offsets outside this range and must not participate in
        # chapter-boundary detection.
        entries_by_book = {}
        for i, entry in enumerate(self._entries):
            book = entry[0]
            offset = entry[4]
            if book in self._book_boundaries:
                b_start, b_end = self._book_boundaries[book]
                if offset < b_start or offset > b_end:
                    continue
            entries_by_book.setdefault(book, []).append(i)

        for book, indices in entries_by_book.items():
            expected = 1
            prev_vs_end = None

            for idx in indices:
                _, ch, vs_start, vs_end, offset = self._entries[idx]

                # Verse-number-based chapter-boundary detection:
                # a steep drop (from high verse to low verse) indicates
                # a new chapter even without an explicit marker.
                if prev_vs_end is not None and vs_start <= 3 and prev_vs_end > 15:
                    expected += 1

                if ch == expected:
                    # Chapter matches expectation — no fix needed
                    pass
                elif ch > expected and ch <= expected + 5 and vs_start <= 10:
                    # Legitimate chapter transition missed by the scanner
                    expected = ch
                else:
                    # Cross-reference pollution or wrong chapter:
                    # correct the entry's chapter without advancing expected
                    self._entries[idx] = (book, expected, vs_start, vs_end, offset)
                    continue

                prev_vs_end = vs_end

    # ==================================================================
    # JA NT-2 format (\r\t dot-notation verse annotations)
    # ==================================================================

    # Generate chapter counts from canon data
    _NT_CHAPTER_COUNTS = [
        (osis_to_spaceless(b.osis), chapter_count(b.osis, CANON_PROTESTANT))
        for b in CANON_PROTESTANT.books
        if b.testament == "nt"
    ]
    _OT_CHAPTER_COUNTS = {
        osis_to_spaceless(b.osis): chapter_count(b.osis, CANON_PROTESTANT)
        for b in CANON_PROTESTANT.books
        if b.testament == "ot"
    }

    # Approximate verse counts for 1-chapter NT books (used for boundary detection)
    _NT_VERSE_COUNTS = {
        "Philemon": 25,
        "2John": 13,
        "3John": 15,
        "Jude": 25,
    }

    _CHAPTER_COUNTS = dict(_OT_CHAPTER_COUNTS)
    _CHAPTER_COUNTS.update(dict(_NT_CHAPTER_COUNTS))

    def _find_book_boundaries_ja_nt2(self):
        """Find book boundaries for JA NT-2 using the binary 66-book index."""
        # Parse the 32-byte book index to get NT book offsets from file start
        # The index has 66 books (OT+NT), each 32 bytes: [len][name][data]
        # We only need NT books, but we parse all to get offsets
        pos = 0x294  # Start of GENESIS entry in JA NT-2
        book_idx = 0
        # Map book index positions to offsets
        book_offsets = {}  # ordinal -> (name, offset_in_file)
        while pos < min(self.size, 0x2000):
            lb = self._read_u8(pos)
            if lb == 0 or lb > 30:
                pos += 32
                continue
            try:
                name = self.mm[pos + 1 : pos + 1 + lb].decode("ascii", errors="replace")
            except Exception:
                pos += 32
                continue
            book_offsets[book_idx] = (name, pos)
            book_idx += 1
            pos += 32
            if book_idx >= 100:
                break

        # Map NT book names from index to canonical names
        _NT_INDEX_MAP = {
            "MATTHEW": "Matthew",
            "MARK": "Mark",
            "LUKE": "Luke",
            "JOHN": "John",
            "ACTS": "Acts",
            "ROMANS": "Romans",
            "1CORINTHIANS": "1Corinthians",
            "2CORINTHIANS": "2Corinthians",
            "GALATIANS": "Galatians",
            "EPHESIANS": "Ephesians",
            "PHILIPPIANS": "Philippians",
            "COLOSSIANS": "Colossians",
            "1THESSALONIANS": "1Thessalonians",
            "2THESSALONIANS": "2Thessalonians",
            "1TIMOTHY": "1Timothy",
            "2TIMOTHY": "2Timothy",
            "TITUS": "Titus",
            "PHILEMON": "Philemon",
            "HEBREWS": "Hebrews",
            "JAMES": "James",
            "1PETER": "1Peter",
            "2PETER": "2Peter",
            "1JOHN": "1John",
            "2JOHN": "2John",
            "3JOHN": "3John",
            "JUDE": "Jude",
            "REVELATION": "Revelation",
        }

        # The text region begins after the binary index + Apocrypha extension + offset table
        # For JA NT-2, the annotation text starts around 0x440000
        # We'll use the \r\t scanning to detect actual book boundaries
        text_start = self.mm.find(b"\r\t", 0x300000)
        if text_start == -1:
            text_start = 0x440000

        for _, entry_tuple in book_offsets.items():
            entry_name = entry_tuple[0]
            canonical = _NT_INDEX_MAP.get(entry_name.upper())
            if canonical:
                self._book_boundaries[canonical] = (text_start, self.size)
                self._volume_starts.append((text_start, canonical))

    def _scan_section_headers_ja_nt2(self):
        """Scan \\r\\t entries, then assign books by chapter-sequence matching.

        Format:
          \\r\\tCH.VERSE[D0]VERSE: Section Title
          \\r\\t.VERSE: Verse annotation text
          \\r\\tVERSE: Verse annotation text
        """
        # Phase 1: scan all entries with placeholder book assignment
        pos = self.mm.find(b"\r\t", 0x300000)
        if pos == -1:
            pos = 0x440000

        scan_end = min(self.size, pos + 0x800000)
        current_chapter = 0
        raw_sections = []  # (ch, v1, v2, offset, chapter_index_in_sequence)

        while pos < scan_end:
            found = self.mm.find(b"\r\t", pos)
            if found == -1:
                break

            content_start = found + 2
            content_end = self.mm.find(b"\r", content_start)
            if content_end == -1:
                content_end = min(self.size, content_start + 200)

            entry_bytes = self.mm[content_start:content_end]
            try:
                entry_text = entry_bytes.decode("latin-1", errors="replace")
            except Exception:
                pos = found + 2
                continue

            entry_text = entry_text.strip()
            if not entry_text or entry_text.startswith("["):
                pos = found + 2
                continue

            normalized = entry_text.replace("\xd0", "-")

            # Section header
            sm = re.match(r"^(\d+)\.(\d+)(?:-(\d+))?\s*:\s*(.+)", normalized)
            if sm:
                ch = int(sm.group(1))
                v1 = int(sm.group(2))
                v2 = int(sm.group(3)) if sm.group(3) else v1
                if 1 <= ch <= 150 and 1 <= v1 <= 176 and 1 <= v2 <= 176:
                    current_chapter = ch
                    raw_sections.append((found, ch, v1, v2))
                    pos = found + 2
                    continue

            # Verse annotation
            vm = re.match(r"^\.?(\d+)(?:-(\d+))?\s*:\s*(.+)", normalized)
            if vm:
                v1 = int(vm.group(1))
                v2 = int(vm.group(2)) if vm.group(2) else v1
                if 1 <= v1 <= 176 and 1 <= v2 <= 176 and current_chapter > 0:
                    raw_sections.append((found, current_chapter, v1, v2))
                pos = found + 2
                continue

            pos = found + 2

        # Phase 2: assign books using chapter-run matching against expected sequence.
        # This is more robust than per-entry transition detection, handling
        # consecutive 1-chapter books and missing section headers correctly.
        nt_books = self._NT_CHAPTER_COUNTS

        # Build chapter runs: list of (ch, count_in_run)
        runs = []
        if raw_sections:
            current_ch = raw_sections[0][1]
            run_count = 0
            for _, ch, _, _ in raw_sections:
                if ch == current_ch:
                    run_count += 1
                else:
                    runs.append((current_ch, run_count))
                    current_ch = ch
                    run_count = 1
            runs.append((current_ch, run_count))

        # Phase 2: assign books using chapter-run matching.
        # Strategy: advance book_idx when chapter numbers indicate a book
        # boundary. For consecutive 1-chapter books sharing a ch1 run, use
        # verse-number resets and proportional splitting to detect boundaries.
        book_idx = 0
        prev_run_ch = 0
        run_offset = 0  # position in raw_sections

        for run_ch, run_count in runs:
            if book_idx >= len(nt_books):
                break

            # Advance when previous run plausibly completed the current book.
            if prev_run_ch > 0:
                expected = nt_books[book_idx][1]
                # Only advance if prev_run_ch could belong to THIS book:
                # prev_run_ch == expected (exact match) OR
                # prev_run_ch == expected - 1 and we see ch1 now (missing last ch)
                if prev_run_ch >= expected:
                    # Guard: if prev_run_ch is from a previous multi-chapter
                    # book (e.g. 5) and current book has 1 chapter, the
                    # prev_run_ch does NOT belong to this book – don't advance.
                    if expected > 1 or prev_run_ch <= expected + 2:
                        book_idx += 1
                elif run_ch == 1 and prev_run_ch >= expected - 1 and prev_run_ch > 1:
                    book_idx += 1

            if book_idx >= len(nt_books):
                break

            book = nt_books[book_idx][0]
            entries_for_current_book = 0

            # Pre-compute: how many consecutive 1-chapter books from here?
            consecutive_1ch = 0
            for j in range(book_idx, len(nt_books)):
                if nt_books[j][1] == 1:
                    consecutive_1ch += 1
                else:
                    break
            is_last_1ch = consecutive_1ch == 1 and nt_books[book_idx][1] == 1

            for i in range(run_count):
                if run_offset + i >= len(raw_sections):
                    break
                offset, ch, v1, v2 = raw_sections[run_offset + i]
                self._entries.append((book, ch, v1, v2, offset))
                entries_for_current_book += 1

                # Mid-run split for 1-chapter books sharing a ch1 run.
                if nt_books[book_idx][1] == 1 and book_idx + 1 < len(nt_books):
                    next_is_1ch = nt_books[book_idx + 1][1] == 1
                    remaining = run_count - entries_for_current_book
                    if next_is_1ch:
                        # Consecutive 1-chapter books: split after proportional share
                        share = max(5, run_count // (consecutive_1ch + 1))
                        if entries_for_current_book >= share:
                            book_idx += 1
                            book = nt_books[book_idx][0]
                            entries_for_current_book = 0
                            consecutive_1ch -= 1
                            # Recompute is_last_1ch for new book
                            is_last_1ch = (
                                consecutive_1ch == 1 and nt_books[book_idx][1] == 1
                            )
                    elif is_last_1ch and remaining > 0:
                        # Last 1-chapter book before multi-chapter: advance when
                        # we've processed a reasonable share.
                        max_verses = self._NT_VERSE_COUNTS.get(book, 30)
                        if entries_for_current_book >= min(max_verses // 3, remaining):
                            book_idx += 1
                            book = nt_books[book_idx][0]
                            entries_for_current_book = 0
                            is_last_1ch = False

            run_offset += run_count
            prev_run_ch = run_ch

        # Post-processing: fix up entries where a 1-chapter book absorbed
        # content from the next book's ch1 (common with Philemon→Hebrews,
        # 2John→3John→Jude→Revelation). If a 1-chapter book's entries span
        # verse numbers well beyond its actual verse count, the excess likely
        # belongs to the next book.
        _nt_name_to_idx = {b: i for i, (b, _) in enumerate(nt_books)}
        for i in reversed(range(len(self._entries))):
            book, ch, v1, v2, offset = self._entries[i]
            max_v = self._NT_VERSE_COUNTS.get(book)
            if max_v is None or ch != 1:
                continue
            # If verse numbers extend well past this book's last verse,
            # the entry likely belongs to the next book.
            if v2 > max_v + 6 or (v1 > max_v and max_v < 20):
                # Find the next book in the canonical sequence
                book_pos = _nt_name_to_idx.get(book, -1)
                if book_pos >= 0 and book_pos + 1 < len(nt_books):
                    next_book = nt_books[book_pos + 1][0]
                    self._entries[i] = (next_book, ch, v1, v2, offset)

        # Build volume_starts and book_boundaries from entry data
        seen_books = set()
        for entry in self._entries:
            book = entry[0]
            if book not in seen_books:
                seen_books.add(book)
                self._volume_starts.append((entry[4], book))
                self._book_boundaries[book] = (entry[4], self.size)

    # ==================================================================
    # Hermeneia format (original)
    # ==================================================================

    def _find_book_boundaries_hermeneia(self):
        """Find volume title pages (5 consecutive \\r bytes) to get book boundaries.

        For Hermeneia modules where 5-CR markers are sparse (AF, Q, 2PS), falls
        back to scanning \\r\\r entries for volume-title-like content: short
        entries (<300 bytes) that contain a known Hermeneia work name
        (e.g. "Ignatius of Antioch") or a "Commentary" marker.
        """
        pattern = b"\r\r\r\r\r"
        pos = 0
        volumes = []
        boundary_markers = []  # [(offset, None), ...] for domain partitioning only

        while True:
            pos = self.mm.find(pattern, pos)
            if pos == -1:
                break

            match_pos = pos
            title_start = pos + 5
            title_end = min(self.size, title_start + 200)
            title_bytes = self.mm[title_start:title_end]
            title_text = title_bytes.decode("latin-1", errors="replace")

            # Skip past all consecutive \r bytes
            pos = title_start
            while pos < self.size and self.mm[pos] == 0x0D:
                pos += 1

            has_title_marker = (
                "Commentary" in title_text[:120] or "\xca" in title_text[:60]
            )
            if has_title_marker:
                book = self._book_from_title_hermeneia(title_text)
                if book and self._is_valid_book_name(book):
                    volumes.append((match_pos, book))
                elif "Commentary" in title_text[:120]:
                    # Unrecognized volume (apocryphal or unmapped) with full
                    # commentary title-page content. Track for domain partitioning
                    # so the previous book's end is correct. Incidental markers
                    # with only Ê (no "Commentary") are not real volume boundaries.
                    boundary_markers.append(match_pos)

        # ---- Fallback: \r\r-entry-based volume detection ----
        # Hermeneia AF/Q/2PS modules have few or no 5-CR markers.  Their
        # volume title pages are regular \r\r entries containing work names
        # (e.g. "Ignatius of Antioch", "Q Source") or "Commentary" markers.
        # Sample \r\r entries in the first 2 MB for volume-title candidates.
        if len(volumes) < 3:
            crr_volumes = self._find_volumes_from_rr_entries()
            for crr_off, crr_book in crr_volumes:
                # Don't duplicate volumes already found via 5-CR scan
                if not any(abs(crr_off - v[0]) < 0x1000 for v in volumes):
                    volumes.append((crr_off, crr_book))

        # ---- Third fallback: 3-CR marker scan (non-Bible Hermeneia) ----
        # Hermeneia AF/Q/2PS modules use \r\r\r markers for volume title
        # pages (e.g. "Hermeneia", "Shepherd of Hermas") instead of the
        # 5-CR markers found in standard Hermeneia modules.  Scan for
        # \r\r\r markers whose title text looks like a volume title page.
        if len(volumes) < 3 and self._is_non_bible_hermeneia():
            crrr_volumes = self._find_volumes_from_rrr_markers()
            for crrr_off, crrr_book in crrr_volumes:
                if not any(abs(crrr_off - v[0]) < 0x1000 for v in volumes):
                    volumes.append((crrr_off, crrr_book))

        volumes.sort(key=lambda x: x[0])
        boundary_markers.sort()
        self._volume_starts = volumes

        # Build book boundaries: use all markers (volumes + unrecognized) for
        # end-boundary calculation so unrecognized volumes cap preceding books.
        all_markers = sorted(volumes.copy())
        for bm in boundary_markers:
            all_markers.append((bm, None))

        for i, (off, book) in enumerate(all_markers):
            if book is None:
                continue  # boundary-only marker, doesn't start a new book entry
            if i + 1 < len(all_markers):
                end = all_markers[i + 1][0]
            else:
                end = self.size
            if book not in self._book_boundaries:
                self._book_boundaries[book] = (off, end)
            else:
                old_start, _ = self._book_boundaries[book]
                self._book_boundaries[book] = (old_start, end)

    def _find_volumes_from_rr_entries(self) -> list[tuple[int, str]]:
        """Scan \\r\\r entries for volume-title candidates.

        A volume-title candidate is a short \\r\\r entry (<300 bytes body)
        whose decoded text contains a known Hermeneia work name (from
        _HERMENEIA_BOOK_TO_CORPUS_WORK in search_index.py) or the word
        "Commentary" in its first line.

        Used as a fallback when 5-CR volume-boundary markers are sparse.
        """
        import re as _vre

        # Known Hermeneia work-name keywords (from _HERMENEIA_BOOK_TO_CORPUS_WORK
        # keys, plus common title fragments).  Maps keyword → canonical book name.
        _VOLUME_KEYWORDS: dict[str, str] = {}
        # Build from _HERMENEIA_BOOK_TO_CORPUS_WORK if available.
        try:
            from accordance_parser.search_index import (
                _HERMENEIA_BOOK_TO_CORPUS_WORK,
            )

            for book_name in _HERMENEIA_BOOK_TO_CORPUS_WORK:
                # Use the book name itself as a keyword (e.g. "IgnatiusofAntioch"
                # → keyword "Ignatius of Antioch" by re-inserting spaces).
                # Also add the last segment as a short keyword.
                _VOLUME_KEYWORDS[book_name] = book_name
                # Short keyword: last camelCase word (e.g. "Didache", "QSource")
                parts = _vre.findall(r"[A-Z][a-z]*", book_name)
                if parts:
                    short = parts[-1] if len(parts) == 1 else "".join(parts[-2:])
                    if len(short) >= 4:
                        _VOLUME_KEYWORDS[short] = book_name
        except ImportError:
            pass

        # Additional common title fragments for AF works
        _TITLE_FRAGMENTS: dict[str, str] = {
            "Ignatius of Antioch": "IgnatiusofAntioch",
            "Ignatius": "IgnatiusofAntioch",
            "Shepherd of Hermas": "ShepherdofHermas",
            "Didache": "Didache",
            "1 Clement": "1Clement",
            "2 Clement": "2Clement",
            "Epistle of Barnabas": "EpistleofBarnabas",
            "Martyrdom of Polycarp": "MartyrdomofPolycarp",
            "Epistle to Diognetus": "EpistletoDiognetus",
            "Q Source": "QSource",
            "Synoptic Sayings": "QSource",
        }
        _VOLUME_KEYWORDS.update(_TITLE_FRAGMENTS)

        volumes: list[tuple[int, str]] = []
        scan_pos = 0x2000
        scan_limit = min(self.size, 0x200000)  # first 2 MB
        seen_books: set[str] = set()

        while scan_pos < scan_limit:
            found = self.mm.find(b"\r\r", scan_pos)
            if found == -1:
                break

            after_start = found + 2
            # Determine body length: find next \r\r or cap at 2000 bytes
            next_rr = self.mm.find(b"\r\r", after_start)
            body_end = next_rr if next_rr != -1 else min(self.size, after_start + 2000)
            body_len = body_end - after_start

            # Only consider short entries as potential title pages
            if body_len > 300 or body_len < 20:
                scan_pos = after_start
                continue

            # Decode and check for title content
            try:
                body_bytes = self.mm[after_start:body_end]
                text = body_bytes.decode("latin-1", errors="replace")
                first_line = text.split("\r")[0].strip()
            except Exception:
                scan_pos = after_start
                continue

            # Skip entries that are clearly not title pages
            if not first_line:
                scan_pos = after_start
                continue
            # Skip: digit-starting section headers
            if first_line and first_line[0].isdigit():
                scan_pos = after_start
                continue
            # Skip: entries with control characters or tabs
            if any(ord(c) < 0x20 and c not in "\r\n" for c in first_line):
                scan_pos = after_start
                continue

            # Check for volume-title markers
            is_title = False
            detected_book = None

            upper_line = first_line.upper()
            if "COMMENTARY" in upper_line or "HERMENEIA" in upper_line:
                is_title = True

            # Check against known Hermeneia work keywords
            for keyword, book_name in _VOLUME_KEYWORDS.items():
                if keyword in text or keyword.upper() in upper_line:
                    is_title = True
                    detected_book = book_name
                    break

            if is_title:
                if detected_book is None:
                    # Use normalized first-line as book name
                    detected_book = _normalize_book(first_line)
                if detected_book and detected_book not in seen_books:
                    seen_books.add(detected_book)
                    volumes.append((found, detected_book))

            scan_pos = after_start

        volumes.sort(key=lambda x: x[0])
        return volumes

    def _find_volumes_from_rrr_markers(self) -> list[tuple[int, str]]:
        """Scan 3-CR (\\r\\r\\r) markers for volume title pages.

        Used as a fallback for non-Bible Hermeneia modules (AF, Q, 2PS)
        where volume boundaries use \\r\\r\\r instead of \\r\\r\\r\\r\\r.
        Each volume title page is a \\r\\r\\r marker followed by a short
        title line (e.g. "Hermeneia", "Shepherd of Hermas") containing
        a known AF work keyword or a "Commentary"/"Hermeneia" marker.
        """
        volumes: list[tuple[int, str]] = []
        seen_books: set[str] = set()
        scan_pos = 0x2000

        while True:
            found = self.mm.find(b"\r\r\r", scan_pos)
            if found == -1:
                break

            # Get text after the 3-CR marker (up to 300 bytes)
            after_end = min(self.size, found + 300)
            title_bytes = self.mm[found + 3 : after_end]
            try:
                title_text = title_bytes.decode("latin-1", errors="replace")
                first_line = title_text.split("\r")[0].strip()
            except Exception:
                scan_pos = found + 3
                continue

            # Skip long lines (body prose, not a title page)
            if not first_line or len(first_line) > 80:
                scan_pos = found + 3
                continue

            # Skip lines starting with digits, [, or control chars
            if first_line[0].isdigit() or first_line[0] in "[":
                scan_pos = found + 3
                continue

            # Check for volume-title markers
            upper_line = first_line.upper()
            has_commentary = "COMMENTARY" in upper_line or "HERMENEIA" in upper_line
            has_af_keyword = self._has_hermeneia_af_keyword(first_line)
            is_volume = has_commentary or has_af_keyword

            if is_volume:
                # Try canonical book detection first
                book = self._detect_book_from_title(first_line)
                if book is None:
                    # Normalize the title text as a book name
                    book = _normalize_book(first_line)
                if book and book not in seen_books and self._is_valid_book_name(book):
                    seen_books.add(book)
                    volumes.append((found, book))

            scan_pos = found + 3

        volumes.sort(key=lambda x: x[0])
        return volumes

    def _book_from_title_hermeneia(self, title: str) -> str | None:
        """Extract canonical book name from a Hermeneia volume title page text."""
        title_clean = title.split("\r")[0].strip()
        for fragment, canonical in HERMENEIA_TITLE_MAP:
            if fragment in title_clean:
                return canonical
        # Fall back to keyword-based detection for OT books etc.
        return self._detect_book_from_title(title_clean)

    def _scan_section_headers_hermeneia(self):
        """Scan Hermeneia entries for verse references (original 3-format logic)."""
        DASH = rb"[-\x96\x97\xd0]"

        paren_ref_re = re.compile(rb"\((\d+):(\d+)(?:\s*" + DASH + rb"\s*(\d+))?\)")
        inline_ref_re = re.compile(
            rb"([A-Z][a-z]+)\s+(\d+):(\d+)(?:\s*" + DASH + rb"\s*(\d+))?"
        )
        bare_ref_re = re.compile(rb"^(\d+):(\d+)(?:\s*" + DASH + rb"\s*(\d+))?[\t ;]")

        inline_books = {
            b"Matthew",
            b"Mark",
            b"Luke",
            b"John",
            b"Acts",
            b"Romans",
            b"Corinthians",
            b"Galatians",
            b"Ephesians",
            b"Philippians",
            b"Colossians",
            b"Thessalonians",
            b"Timothy",
            b"Titus",
            b"Philemon",
            b"Hebrews",
            b"James",
            b"Peter",
            b"Jude",
            b"Revelation",
            b"Genesis",
            b"Exodus",
            b"Leviticus",
            b"Numbers",
            b"Deuteronomy",
            b"Joshua",
            b"Judges",
            b"Ruth",
            b"Samuel",
            b"Kings",
            b"Chronicles",
            b"Ezra",
            b"Nehemiah",
            b"Esther",
            b"Job",
            b"Psalms",
            b"Proverbs",
            b"Ecclesiastes",
            b"Song",
            b"Isaiah",
            b"Jeremiah",
            b"Lamentations",
            b"Ezekiel",
            b"Daniel",
            b"Hosea",
            b"Joel",
            b"Amos",
            b"Obadiah",
            b"Jonah",
            b"Micah",
            b"Nahum",
            b"Habakkuk",
            b"Zephaniah",
            b"Haggai",
            b"Zechariah",
            b"Malachi",
        }

        pos = self._volume_starts[0][0] if self._volume_starts else 0x03400000
        current_book = None
        current_chapter = 1

        while pos < self.size:
            pos = self.mm.find(b"\r\r", pos)
            if pos == -1:
                break

            for vol_off, vol_book in self._volume_starts:
                if pos < vol_off:
                    break
                current_book = vol_book

            if current_book is None:
                pos += 2
                continue

            header_end = min(self.size, pos + 256)
            header = self.mm[pos + 2 : header_end]

            if header[:1] == b"\t":
                pos += 2
                continue
            if header[:1] == b"[" and not header[:5].startswith(b"[vol."):
                pos += 2
                continue

            header_text = header.decode("latin-1", errors="replace")
            first_line = header_text.split("\r")[0].strip()

            chapter = None
            verse_start = None
            verse_end = None

            # Ü-prefixed section headers (Hermeneia OT2/alternate volumes):
            #   "Ü 1:3 text"  -> ch:v
            #   "Ü 1 text (6:1-7)" -> verse-only with parenthetical chapter ref
            if header[:1] == b"\xdc":
                after_udc = header[1:60]
                # Try "Ü ch:v" pattern
                udc_chv = re.match(rb"\s*(\d+):(\d+)", after_udc)
                if udc_chv:
                    chapter = int(udc_chv.group(1))
                    verse_start = int(udc_chv.group(2))
                    verse_end = verse_start
                else:
                    # Try "Ü v text" with parenthetical (ch:v-range), or use current_chapter
                    udc_verse = re.match(rb"\s*(\d+)\s", after_udc)
                    if udc_verse:
                        verse_start = int(udc_verse.group(1))
                        paren = paren_ref_re.search(header[:200])
                        if paren:
                            chapter = int(paren.group(1))
                            verse_end = (
                                int(paren.group(3)) if paren.group(3) else verse_start
                            )
                        else:
                            chapter = current_chapter
                            verse_end = verse_start

            if chapter is None:
                match = bare_ref_re.search(header[:150])
                if match:
                    chapter = int(match.group(1))
                    verse_start = int(match.group(2))
                    verse_end = int(match.group(3)) if match.group(3) else verse_start

            if chapter is None:
                match = paren_ref_re.search(header[:200])
                if match and self._looks_like_section_header(first_line):
                    chapter = int(match.group(1))
                    verse_start = int(match.group(2))
                    verse_end = int(match.group(3)) if match.group(3) else verse_start

            if chapter is None and self._looks_like_section_header(first_line):
                match = inline_ref_re.search(header[:150])
                if match:
                    book_name = match.group(1)
                    if book_name in inline_books:
                        chapter = int(match.group(2))
                        verse_start = int(match.group(3))
                        verse_end = (
                            int(match.group(4)) if match.group(4) else verse_start
                        )

            if chapter is None:
                # Check for non-verse section headers (Excursus, Introduction, etc.).
                # These entries have no verse reference but are real section headers
                # that should be indexed (chapter=0, verse=0 as a special marker).
                if self._looks_like_section_header(first_line):
                    chapter = 0
                    verse_start = 0
                    verse_end = 0

            if chapter is not None:
                if chapter == 0:
                    # Non-verse section header (Excursus, Introduction, etc.)
                    self._entries.append((current_book, 0, 0, 0, pos))
                else:
                    # Validate chapter number against book's known maximum.
                    # Prevents non-section-header text from being misparsed as ch:v refs
                    # (e.g., page numbers, cross-ref verse numbers).
                    # Short books (≤5 chapters) use a broad bound (150) to handle
                    # multi-book Hermeneia volumes (e.g. Joel+Amos mapped to Joel).
                    # Longer books use max_ch + 5 to catch clear aliasing (e.g. ch=42
                    # in Daniel which has 12 chapters).
                    max_ch = self._CHAPTER_COUNTS.get(current_book, 150)
                    ch_limit = 150 if max_ch <= 5 else max_ch + 5
                    is_valid = (
                        1 <= chapter <= ch_limit
                        and verse_start is not None
                        and 1 <= verse_start <= 176
                    )
                    if is_valid:
                        self._entries.append(
                            (current_book, chapter, verse_start, verse_end, pos)
                        )
                        current_chapter = chapter

            pos += 2

    @staticmethod
    def _is_valid_book_name(book: str) -> bool:
        """Return True if *book* looks like a legitimate book name.

        Rejects names containing control characters (except space/newline),
        tab characters, colons surrounded by special chars (e.g. "Rom.4.3:\tcondemned"),
        and names shorter than 2 characters.
        """
        if not book or len(book) < 2:
            return False
        # Reject names with control chars (0x00-0x1F except \t, \r, \n)
        if any(ord(c) < 0x20 and c not in "\t\r\n " for c in book):
            return False
        # Reject names with tab characters
        if "\t" in book:
            return False
        # Reject names that look like inline refs or random text:
        # patterns like "X.Y:\ttext" or "[p. XX]"
        import re as _vnre

        if _vnre.search(r":\t", book):
            return False
        if _vnre.search(r"\.\d+:\t", book):
            return False
        if book.startswith("["):
            return False
        return True

    @staticmethod
    def _has_hermeneia_af_keyword(text: str) -> bool:
        """Return True if the text contains a known Hermeneia AF work keyword.

        Used to distinguish real volume title pages from incidental \r\r\r
        markers in body text (author bios, "Footnotes", "Abbreviations", etc.).
        """
        _AF_KEYWORDS = frozenset(
            {
                "Ignatius",
                "Didache",
                "Clement",
                "Polycarp",
                "Barnabas",
                "Diognetus",
                "Hermas",
                "Shepherd",
                "Martyrdom",
                "Q Source",
                "Synoptic",
                "Enoch",
                "Pseudepigrapha",
                "Apostolic Fathers",
            }
        )
        upper = text.upper()
        return any(kw.upper() in upper for kw in _AF_KEYWORDS)

    def _looks_like_section_header(self, text: str) -> bool:
        """Check if text looks like a commentary section header."""
        if not text:
            return False
        if text.startswith("\xdc"):
            return True
        if text and text[0].isdigit():
            rest = text[1:]
            if rest and rest[0] in ". -":
                return True
            if len(text) >= 3 and text[1] == " " and text[2].isupper():
                return True
            return False
        if len(text) >= 2 and text[0].isupper() and text[1] == " ":
            return True
        for prefix in [
            "Excursus",
            "Analysis",
            "Interpretation",
            "Summary",
            "Sources",
            "Origin",
            "Literature",
            "History",
            "Structure",
            "Commentary",
            "Foreword",
            "Preface",
            "Introduction",
            "Conclusion",
            "Author",
            "Title",
            "Bibliography",
        ]:
            if text.startswith(prefix):
                return True
        return False

    @staticmethod
    def _clean_text(text: str) -> str:
        """Remap Mac OS Roman bytes decoded as latin-1 to proper Unicode.

        Also truncates at the first null byte to prevent binary data
        leakage from module tails (e.g. CEB2 Notes, Josephus Crossrefs).
        """
        null_pos = text.find("\x00")
        if null_pos >= 0:
            text = text[:null_pos]
        for byte_val, replacement in CommentaryModule._MAC_ROMAN_MAP.items():
            text = text.replace(chr(byte_val), replacement)
        return text

    # ==================================================================
    # Generic format (type-6 General Tool modules with \r\r delimiters)
    # ==================================================================

    def _build_index_generic(self):
        """Index all \\r\\r markers as numbered sections.

        For type-6 General Tool modules (Archaeology of Bible, Robertson Greek,
        People & Places, etc.), each \\r\\r marker is an entry boundary.
        Since these modules don't map to biblical verses, entries are indexed
        by sequential section number.

        Falls back to \\r-delimited entry scanning for reading-plan modules
        (Chronological/Devotional Readings) that use single-CR delimiters.
        """
        # Use the module title as the "book" name
        book = self.title.strip() or self.abbrev

        # Detect reading-plan format: \r\t dominant over \r\r
        # Cache for reuse in _read_section_generic() to avoid re-scanning
        # the file on every section read (thousands of duplicate 8MB scans).
        rr_count = self._count_rr_markers()
        rt_count = 0
        pos = 0
        while pos < min(self.size, 0x800000):
            found = self.mm.find(b"\r\t", pos)
            if found == -1:
                break
            rt_count += 1
            pos = found + 2
            if rt_count > 50000:
                break
        self._rr_count = rr_count
        self._rt_count = rt_count

        use_cr_delim = rt_count > rr_count * 5 and rr_count < 50

        # Scan all entry markers starting after the header region
        pos = 0x2000
        section_num = 0

        if use_cr_delim:
            # Reading-plan format: entries delimited by \r followed by
            # date/month pattern or section title (Introduction, etc.)
            import re as _re4

            entry_pat = _re4.compile(
                rb"\r(January|February|March|April|May|June|July|August|"
                rb"September|October|November|December|Introduction|Devotional|"
                rb"Chronological)"
            )
            for m in entry_pat.finditer(self.mm[pos:]):
                section_num += 1
                self._entries.append((book, section_num, 0, 0, pos + m.start()))
            # Set book boundaries for reading-plan modules
            if self._entries:
                first_offset = self._entries[0][4]
                self._volume_starts = [(first_offset, book)]
                self._book_boundaries[book] = (first_offset, self.size)
            return

        consecutive_binary = 0

        while pos < self.size - 2:
            found = self.mm.find(b"\r\r", pos)
            if found == -1:
                break

            after_start = found + 2

            # Skip font encoding table entries: glyph-encoding tables have
            # very high \r density (\r every 4-8 bytes internally) vs real
            # text entries (1-2 \r per paragraph).  Without this guard, big
            # font tables (e.g. LBLA Notes, NBLH Notes) poison the entry
            # builder and cause the scan to halt in binary territory before
            # reaching the real commentary content.
            body_preview = self.mm[after_start : min(self.size, after_start + 200)]
            cr_count_in_preview = sum(1 for b in bytes(body_preview) if b == 0x0D)
            if cr_count_in_preview > 15:
                pos = found + 2
                continue

            # Find the next \r\r boundary now so both the pre-scan and
            # density check can use the actual section body span.
            next_rr = self.mm.find(b"\r\r", found + 2)

            # Pre-scan: if the section content between \r\r markers is >30%
            # non-printable bytes, we've entered the binary tail — stop.
            # Sample between \r\r markers (not a fixed 0x2000 window) to avoid
            # crossing into later sections' binary territory — e.g. the Z body
            # in People & Places stretches ~8 MB past the last valid content.
            # Only check sections with meaningful body length (>= 64 bytes) so
            # short single-letter headers and format markers aren't flagged.
            if section_num > 0:
                section_end = next_rr if next_rr != -1 else self.size
                body_len = section_end - after_start
                if body_len >= 64:
                    ps_len = min(body_len, 0x2000)
                    ps = bytes(self.mm[after_start : after_start + ps_len])
                    non_printable = sum(
                        1
                        for b in ps
                        if not (
                            b in (0x0D, 0x0A, 0x09)
                            or 0x20 <= b <= 0x7E
                            or b in self._MAC_ROMAN_MAP
                        )
                    )
                    if non_printable / ps_len > 0.30:
                        consecutive_binary += 1
                        # Embedded images (e.g. JPS Haggadah) produce single
                        # binary sections; only stop after a run of 5.
                        if consecutive_binary >= 5 and section_num > 0:
                            break
                        pos = found + 2
                        continue
                    else:
                        # Section body is clean — reset the consecutive-
                        # binary counter so isolated binary sections (e.g.
                        # embedded images) don't accumulate toward the
                        # threshold across valid content.
                        consecutive_binary = 0

            # Skip binary/data sections using multi-signal heuristic
            if after_start + 60 < self.size:
                check = bytes(self.mm[after_start : after_start + 60])
                nulls = sum(1 for b in check if b == 0x00)
                ctrl = sum(1 for b in check if 0x01 <= b <= 0x08 or 0x0E <= b <= 0x1F)
                # Require at least one 3-letter ASCII word run; reject heavy
                # control-char regions (binary index data)
                import re as _re_gen

                has_word = bool(_re_gen.search(rb"[a-zA-Z]{3,}", check))
                is_binary = (
                    not has_word
                    or nulls > len(check) * 0.10
                    or ctrl > len(check) * 0.20
                )
                if is_binary:
                    consecutive_binary += 1
                    # After 5 consecutive binary \r\r markers, we've
                    # entered the binary tail region — stop scanning.
                    # Guard: only break if we've already found some valid
                    # sections (section_num > 0).  Modules like Guide to BH
                    # Vocab have runtime-encoded binary throughout the whole
                    # file — we should skip those entries but not stop early.
                    if consecutive_binary >= 5 and section_num > 0:
                        break
                    pos = found + 2
                    continue
                else:
                    consecutive_binary = 0

            # Sanity check: skip entries that are suspiciously far from previous
            # one — the next \r\r boundary may be deep in binary data.
            # If the gap to the next \r\r exceeds 500KB, skip this entry.
            # (next_rr was computed before the pre-scan above.)
            if next_rr != -1 and next_rr - found > 500_000:
                consecutive_binary += 1
                if consecutive_binary >= 5 and section_num > 0:
                    break
                pos = found + 2
                continue

            # Binary-tail region detection: compute ASCII+whitespace+MacRoman
            # density of the full section body.  Binary garbage (compressed
            # data, serialized structures) typically has <<70% printable bytes,
            # while real text sections are >>95%.  Only check sections with
            # substantial body length (>= 64 bytes) to avoid false positives
            # on short format-marker sections.
            if next_rr != -1:
                body_len = next_rr - (found + 2)
            else:
                body_len = self.size - (found + 2)
            if body_len >= 64:
                sample_len = min(body_len, 0x2000)  # Cap at 8 KB
                sample = bytes(self.mm[found + 2 : found + 2 + sample_len])
                text_bytes = sum(
                    1
                    for b in sample
                    if b == 0x0D
                    or b == 0x0A
                    or b == 0x09
                    or 0x20 <= b <= 0x7E
                    or b in self._MAC_ROMAN_MAP
                )
                density = text_bytes / sample_len
                if density < 0.70:
                    consecutive_binary += 1
                    if consecutive_binary >= 5 and section_num > 0:
                        break
                    pos = found + 2
                    continue

            section_num += 1

            # Store entry: (book, section_number, 0, 0, offset)
            self._entries.append((book, section_num, 0, 0, found))

            pos = found + 2

        # ── Fallback: tab-separated entries on single-CR delimiters ────
        # Some modules (e.g. "Classic Passages") use \r\r only as a
        # metadata/content boundary, then delimit individual entries with
        # single \r and separate title/reference fields with \t.
        # The main \r\r scan above produces 0 entries for these modules
        # because binary-index regions consume most \r\r markers and the
        # content section (spanning from the last \r\r to EOF) blends text
        # with a binary tail, falling below the 70 % printable-density
        # threshold.  Here we find the first \r\r marker that opens onto
        # readable text, then scan forward for the binary-transition point
        # and split the text region into \r-delimited, \t-separated entries.
        if section_num == 0:
            import re as _re_tab

            scan_pos = 0x2000
            while scan_pos < self.size - 2:
                found_tab = self.mm.find(b"\r\r", scan_pos)
                if found_tab == -1:
                    break
                after_tab = found_tab + 2
                if after_tab + 4 >= self.size:
                    scan_pos = after_tab
                    continue

                # Quick text check: 3+ letter ASCII word and few nulls
                peek = bytes(self.mm[after_tab : min(self.size, after_tab + 60)])
                has_word = bool(_re_tab.search(rb"[a-zA-Z]{3,}", peek))
                nulls = sum(1 for b in peek if b == 0x00)
                if not has_word or nulls > 5:
                    scan_pos = after_tab
                    continue

                # Find the end of the text region (binary transition).
                # Walk the file in 200-byte steps until a block shows
                # > 25 % non-printable bytes, then scan forward within
                # that block byte-by-byte to locate where the binary
                # region actually begins (first byte of a mainly-binary
                # 40-byte window).  Falls back to the block start if
                # no clear boundary is found.
                text_end = after_tab
                while text_end < self.size - 10:
                    probe = bytes(self.mm[text_end : min(self.size, text_end + 200)])
                    non_print = sum(
                        1
                        for b in probe
                        if not (
                            b in (0x0D, 0x0A, 0x09)
                            or 0x20 <= b <= 0x7E
                            or b in self._MAC_ROMAN_MAP
                        )
                    )
                    if non_print / len(probe) > 0.25:
                        # Scan the probe byte-by-byte for the transition.
                        found_boundary = False
                        for j in range(len(probe)):
                            b = probe[j]
                            if not (
                                b in (0x0D, 0x0A, 0x09)
                                or 0x20 <= b <= 0x7E
                                or b in self._MAC_ROMAN_MAP
                            ):
                                # Check the next 40-byte window: if
                                # > 40 % of it is non-printable we've
                                # entered the binary tail.
                                window = probe[j : min(len(probe), j + 40)]
                                if len(window) >= 10:
                                    w_non = sum(
                                        1
                                        for bw in window
                                        if not (
                                            bw in (0x0D, 0x0A, 0x09)
                                            or 0x20 <= bw <= 0x7E
                                            or bw in self._MAC_ROMAN_MAP
                                        )
                                    )
                                    if w_non / len(window) > 0.40:
                                        text_end += j
                                        found_boundary = True
                                        break
                        if not found_boundary:
                            text_end += len(probe)
                        break
                    text_end += len(probe)

                # Parse the text region: split on \r, emit entries for
                # lines containing a \t separator.
                content_blob = bytes(self.mm[after_tab:text_end])
                content_str = content_blob.decode("latin-1", errors="replace")
                entries_found = 0
                cur_off = after_tab  # running byte offset within the file
                for raw_line in content_str.split("\r"):
                    stripped = raw_line.strip()
                    if stripped and "\t" in stripped:
                        section_num += 1
                        self._entries.append((book, section_num, 0, 0, cur_off))
                        entries_found += 1
                    # Advance cur_off past this line + its \r delimiter
                    cur_off += len(raw_line.encode("latin-1", errors="replace")) + 1

                if entries_found >= 5:
                    # Signal the reader to use single-CR delimiters
                    self._generic_single_cr = True
                break  # only process the first text-bearing region

                scan_pos = after_tab

        # Post-processing: trim binary-garbage sections from the tail.
        # Check full section content (between \r\r markers), not just the
        # first 200 bytes.  Works on all module sizes, not just >50 entries.
        # Skip for single-CR tab-separated entries (e.g. Classic Passages)
        # whose offsets point directly to entry text, not \r\r delimiters.
        if not getattr(self, "_generic_single_cr", False):
            while self._entries:
                last_offset = self._entries[-1][4]
                body_start = last_offset + 2
                next_rr = self.mm.find(b"\r\r", body_start)
                section_end = next_rr if next_rr != -1 else self.size
                section_len = section_end - body_start
                if section_len <= 0:
                    self._entries.pop()
                    continue
                # Cap sample at 64 KB to keep trim fast on large tail sections
                sample_end = min(section_end, body_start + 0x10000)
                sample = bytes(self.mm[body_start:sample_end])
                # Density-based check: count printable/text bytes vs total
                text_bytes = sum(
                    1
                    for b in sample
                    if b == 0x0D
                    or b == 0x0A
                    or b == 0x09
                    or 0x20 <= b <= 0x7E
                    or b in self._MAC_ROMAN_MAP
                )
                total = len(sample) if sample else 1
                text_ratio = text_bytes / total
                null_ratio = (sum(1 for b in sample if b == 0x00)) / total
                # Binary region: < 60 % printable text OR > 15 % null bytes
                is_binary = text_ratio < 0.60 or null_ratio > 0.15
                if is_binary:
                    self._entries.pop()
                else:
                    break

        # Set book boundaries
        if self._entries:
            first_offset = self._entries[0][4]
            self._volume_starts = [(first_offset, book)]
            self._book_boundaries[book] = (first_offset, self.size)

    def _read_section_generic(self, offset: int) -> str:
        """Read a generic section from offset to next delimiter.

        Uses \\r\\r as default delimiter; falls back to \\r for reading-plan
        modules (Chronological/Devotional Readings) that use single-CR.
        """
        # Use cached counts from _build_index_generic() to avoid
        # re-scanning the file on every section read.
        rr_count = getattr(self, "_rr_count", None)
        rt_count = getattr(self, "_rt_count", None)
        if rr_count is None or rt_count is None:
            # Fallback: recompute (shouldn't happen; generic build sets these)
            rr_count = self._count_rr_markers()
            rt_count = 0
            rpos = 0
            while rpos < min(self.size, 0x800000):
                found = self.mm.find(b"\r\t", rpos)
                if found == -1:
                    break
                rt_count += 1
                rpos = found + 2
                if rt_count > 50000:
                    break
        use_cr_delim = rt_count > rr_count * 5 and rr_count < 50
        use_single_cr = getattr(self, "_generic_single_cr", False)

        if use_single_cr:
            # Tab-separated entries on single-CR delimiters
            # (e.g. Classic Passages).  Each entry is one line.
            end_offset = self.mm.find(b"\r", offset)
            if end_offset == -1:
                end_offset = self.size
        elif use_cr_delim:
            # \r-delimited entries: find next \r that starts a new entry
            import re as _re5

            end_offset = self.size
            entry_pat2 = _re5.compile(
                rb"\r(January|February|March|April|May|June|July|August|"
                rb"September|October|November|December|Introduction|Devotional|"
                rb"Chronological)"
            )
            m = entry_pat2.search(self.mm, offset + 1)
            if m:
                end_offset = m.start()
        else:
            end_offset = self.mm.find(b"\r\r", offset + 2)
            if end_offset == -1:
                end_offset = self.size

        raw = self.mm[
            offset + (0 if use_single_cr else (1 if use_cr_delim else 2)) : end_offset
        ]
        text = decode(raw, no_greek=self._no_greek)
        text = self._clean_text(text)
        text = text.replace("\r", "\n")
        text = text.replace("\t", "    ")
        return text.strip()

    # ==================================================================
    # Lookup (shared across formats)
    # ==================================================================

    def get_section(self, reference: str) -> str:
        """Return the commentary section for a Bible reference like 'Matthew 3:5'.

        For generic-format modules, accepts a section number like '42'.
        For apparatus modules, loads decoded text from pre-extracted JSON.
        """
        if self._format == "apparatus":
            return self._get_apparatus_section(reference)

        if self._format == "generic":
            # If this is the Metzger module, try the verse-index lookup first
            if "metzger" in self.abbrev.lower():
                index = self._load_metzger_index()
                try:
                    book, chapter, verse = parse_reference(reference)
                    key = f"{book}_{chapter}:{verse}"
                    section_ids = index.get(key, [])
                    if section_ids:
                        # Read all matching sections, concatenated
                        parts = []
                        for sid in section_ids:
                            for entry in self._entries:
                                if entry[1] == sid:
                                    text = self._read_section_generic(entry[4])
                                    if text:
                                        parts.append(text)
                                    break
                        if parts:
                            return "\n\n---\n\n".join(parts)
                    else:
                        # Reference parsed but not in the Metzger index —
                        # the verse has no text-critical variant covered.
                        return f"[No commentary found for {reference}]"
                except ValueError:
                    pass  # Not a parsable verse reference; fall through

            # Parse as section number query
            ref = reference.strip()
            try:
                section_num = int(ref)
            except ValueError:
                return (
                    f"[Invalid section reference: '{ref}'. "
                    f"This module ({getattr(self, '_abbrev', '?') or '?'}) "
                    f"uses numeric section IDs, not verse references. "
                    f"Use list_sections() or browse_tool() to discover "
                    f"available sections (1–{len(self._entries or [])}).]"
                )

            for entry in self._entries:
                if entry[1] == section_num:
                    return self._read_section_generic(entry[4])
            return f"[Section {section_num} not found]"

        book, chapter, verse = parse_reference(reference)

        # Handle chapter 0 queries: book introductions and non-verse sections.
        # These entries use chapter=0, verse=0 as a marker and are matched by
        # book alone (any verse/chapter=0 reference resolves to the intro).
        if chapter == 0:
            intro_entries = [e for e in self._entries if e[0] == book and e[1] == 0]
            if intro_entries:
                intro_entries.sort(key=lambda e: e[4])
                return self._read_section(intro_entries[0][4])
            return f"[No introduction found for {book}]"

        # Step 1: Exact match - same book+chapter, verse within range
        candidates = [
            e
            for e in self._entries
            if e[0] == book and e[1] == chapter and e[2] <= verse <= e[3]
        ]
        if candidates:
            candidates.sort(key=lambda e: e[3] - e[2])
            return self._read_section(candidates[0][4])

        # Step 2: Same chapter, any verse (return first section)
        chapter_entries = [e for e in self._entries if e[0] == book and e[1] == chapter]
        if chapter_entries:
            chapter_entries.sort(key=lambda e: e[4])
            return self._read_section(chapter_entries[0][4])

        # Step 3: Nearby chapters (+-1) — with warning prefix to prevent
        # silent wrong-chapter returns that mislead LLM consumers.
        nearby = [e for e in self._entries if e[0] == book and abs(e[1] - chapter) <= 1]
        if nearby:
            nearby.sort(key=lambda e: (abs(e[1] - chapter), e[4]))
            fallback = nearby[0]
            prefix = (
                f"[Note: No exact match for {reference}. "
                f"Showing nearby entry ({fallback[0]} {fallback[1]}:{fallback[2]}-{fallback[3]}).]\n\n"
            )
            return prefix + self._read_section(fallback[4])

        # Step 4: Any entry in this book — with warning prefix.
        book_entries = [e for e in self._entries if e[0] == book]
        if book_entries:
            book_entries.sort(key=lambda e: abs(e[1] - chapter))
            fallback = book_entries[0]
            prefix = (
                f"[Note: No exact match for {reference}. "
                f"Showing nearest available entry ({fallback[0]} {fallback[1]}:{fallback[2]}-{fallback[3]}).]\n\n"
            )
            return prefix + self._read_section(fallback[4])

        # Step 5: Raw fallback from book boundary — with warning.
        if book in self._book_boundaries:
            start, _ = self._book_boundaries[book]
            prefix = (
                f"[Note: No exact match for {reference}. "
                f"Showing raw fallback from book boundary for {book}.]\n\n"
            )
            return prefix + self._read_raw_section(start, 5000)

        return f"[No commentary found for {reference}]"

    def _read_section(self, start_offset: int) -> str:
        """Read commentary text from start_offset to the next major section break.

        Break logic varies by format:
          - generic: reads to next \\r\\r marker
          - JFB: reads through verse-level entries until next chapter/book section header
          - Hermeneia: reads until next structured section header
          - ja_nt2: reads until next \\r\\t section/verse boundary
        """
        if self._format == "generic":
            return self._read_section_generic(start_offset)
        elif self._format == "jps":
            return self._read_jps_section(start_offset)
        elif self._format == "treasury":
            return self._read_treasury_section(start_offset)
        elif self._format == "jfb":
            is_intro = any(e[4] == start_offset and e[1] == 0 for e in self._entries)
            return self._read_section_jfb(start_offset, is_intro)
        elif self._format == "ja_nt2":
            return self._read_section_ja_nt2(start_offset)
        else:
            # Check if this is a non-verse section (chapter=0) so the
            # reader can use broader break conditions for introductions.
            is_intro = any(e[4] == start_offset and e[1] == 0 for e in self._entries)
            return self._read_section_hermeneia(start_offset, is_intro)

    def _read_section_jfb(self, start_offset: int, is_intro: bool = False) -> str:
        """JFB: read through verse entries until next chapter/section boundary.

        Barnes/Clarke use verse-level entries (each \\r\\r is a separate verse),
        while JFB uses multi-verse sections with \\r\\r sub-entries inside.
        We detect the entry style by peeking at the first byte: if it's a digit,
        we're in verse-by-verse mode and digit-starting entries end the section.

        For intro sections (is_intro=True), the section reads from the book
        boundary through all introductory matter until the first real verse
        entry.  Uppercase headings (e.g., "INTRODUCTION") within an intro
        do NOT cause a break.
        """
        end_offset = start_offset + 2

        # Detect entry style: Barnes (digit-starting) vs JFB (book-ref heading)
        peek_byte = self.mm[start_offset + 2 : start_offset + 3]
        is_verse_by_verse = peek_byte[:1].isdigit()

        while end_offset < self.size:
            next_pos = self.mm.find(b"\r\r", end_offset)
            if next_pos == -1:
                end_offset = self.size
                break

            after_rr = self.mm[next_pos + 2 : min(self.size, next_pos + 42)]
            is_break = False

            if after_rr[:3] == b"\r\r\r":
                is_break = True
            elif after_rr[:8] == b"CHAPTER ":
                is_break = True
            elif after_rr[:1] == b"[":
                is_break = True
            elif after_rr[:1].isdigit():
                # Digit-starting entries: always a break for intro sections
                # (the first verse-numbered entry ends the introduction).
                # For regular sections, only break in verse-by-verse mode.
                if is_intro:
                    is_break = True
                elif is_verse_by_verse:
                    is_break = True
            elif after_rr[:1].isalpha() and 0x41 <= after_rr[0] <= 0x5A:
                header_sample = after_rr[:40].decode("latin-1", errors="replace")
                if re.search(r"[A-Z][a-z]+\s+\d+:\d+", header_sample):
                    is_break = True
                elif re.match(r"[Vv]erse\s+\d+", header_sample):
                    # "Verse N." pattern (Clarke) — always a section boundary
                    is_break = True
                elif (
                    not is_intro
                    and header_sample[:15].isupper()
                    and len(header_sample.strip()) > 10
                ):
                    is_break = True

            if is_break:
                end_offset = next_pos
                break

            end_offset = next_pos + 2

        raw = self.mm[start_offset + 2 : end_offset]
        text = decode(raw, no_greek=self._no_greek)
        text = self._clean_text(text)
        text = text.replace("\r", "\n")
        text = text.replace("\t", "    ")

        return text.strip()

    # ==================================================================
    # JPS format (magic 0x18) — [Book., p. NNN] page-based commentary
    # ==================================================================

    _JPS_BOOK_MAP = {
        "Gen": "Genesis",
        "Exod": "Exodus",
        "Lev": "Leviticus",
        "Num": "Numbers",
        "Deut": "Deuteronomy",
    }

    def _build_jps_index(self):
        """Build index for JPS Torah Commentary format.

        Scans for ``[Book., p. NNN]`` section headers, maps chapter numbers
        from ``\\r\\rchapter N`` markers in the body text, and populates
        ``_entries``, ``_volume_starts``, and ``_book_boundaries``.
        """
        import re as _jps_re

        # --- Phase 1: Scan all section markers ---
        sec_pattern = _jps_re.compile(rb"\[([A-Z][a-z]+)\.\,\s*p\.\s*(\d+)\]")
        raw_sections = []  # [(offset, book_abbr, page_num), ...]
        for m in sec_pattern.finditer(self.mm):
            offset = m.start()
            book_abbr = m.group(1).decode("latin-1")
            page_num = int(m.group(2))
            # Skip binary index area before real content (offsets < 0x100000)
            if offset < 0x100000:
                continue
            book_canon = self._JPS_BOOK_MAP.get(book_abbr)
            if book_canon is None:
                continue
            raw_sections.append((offset, book_canon, page_num))

        if not raw_sections:
            return

        # --- Phase 2: Build chapter→page mapping ---
        # Scan text between section markers for "\\r\\rchapter N" patterns.
        chapter_map = {}  # {book_canon: {chapter_num: page_num}}
        for i, (sec_off, book, page) in enumerate(raw_sections):
            # Determine end of this section's text region
            if i + 1 < len(raw_sections):
                next_off = raw_sections[i + 1][0]
            else:
                next_off = self.size

            # Skip past the [Book., p. NNN] header to get body text
            header_end = self.mm.find(b"]", sec_off) + 1
            body_start = sec_off
            crr = self.mm.find(b"\r\r", header_end)
            if crr != -1 and crr - header_end < 10:
                body_start = crr

            text_slice = self.mm[body_start:next_off]

            # Look for "\\r\\rchapter N" at line start (chapter section headers)
            ch_match = _jps_re.search(rb"\r\rchapter\s+(\d+)", text_slice)
            if ch_match:
                ch = int(ch_match.group(1))
                chapter_map.setdefault(book, {})[ch] = page

        # --- Phase 3: Build entries ---
        # Each entry: (book, chapter, 1, 999, offset)
        # where offset points to the [Book., p. NNN] marker.
        seen = set()
        for sec_off, book, page in raw_sections:
            # Find which chapter this page belongs to
            ch = None
            book_chapters = chapter_map.get(book, {})
            # Walk chapters sorted by page to find the right one
            sorted_chapters = sorted(book_chapters.items(), key=lambda x: x[1])
            for chapter, ch_page in sorted_chapters:
                if page >= ch_page:
                    ch = chapter
                else:
                    break

            if ch is None:
                # Page before the first chapter — intro matter (chapter 0)
                ch = 0

            key = (book, ch)
            if key not in seen:
                seen.add(key)
                self._entries.append((book, ch, 1, 999, sec_off))

        # Sort entries by offset for stable lookup order
        self._entries.sort(key=lambda e: e[4])

        # --- Phase 4: Build volume_starts and book_boundaries ---
        first_offsets = {}
        last_offsets = {}
        for sec_off, book, _page in raw_sections:
            if book not in first_offsets:
                first_offsets[book] = sec_off
            last_offsets[book] = sec_off

        for book in ("Genesis", "Exodus", "Leviticus", "Numbers", "Deuteronomy"):
            if book in first_offsets:
                self._volume_starts.append((first_offsets[book], book))

        self._volume_starts.sort(key=lambda x: x[0])

        vol_list = [(b, o) for o, b in self._volume_starts]
        for i, (book, start) in enumerate(vol_list):
            if i + 1 < len(vol_list):
                end_off = first_offsets.get(vol_list[i + 1][0], self.size)
            else:
                end_off = self.size
            self._book_boundaries[book] = (start, end_off)

    def _read_jps_section(self, start_offset: int) -> str:
        """Read JPS commentary section from ``[Book., p. NNN]`` marker.

        Reads the body text between the section header and the next
        ``[`` bracket marker (or end of file).  The body starts after
        the ``\\r\\r`` that follows the ``[Book., p. NNN]`` header.
        """
        # Find the end of the header: the "]\\r\\r" sequence
        bracket_end = self.mm.find(b"]", start_offset)
        if bracket_end == -1:
            bracket_end = start_offset

        # Body starts after the \r\r following the bracket header
        body_start = bracket_end + 1
        crr = self.mm.find(b"\r\r", body_start)
        if crr != -1 and crr - body_start < 10:
            body_start = crr + 2

        # Find next "[" bracket marker — this is the next section/page boundary
        end_offset = self.mm.find(b"[", body_start)
        if end_offset == -1:
            end_offset = self.size

        raw = self.mm[body_start:end_offset]
        text = decode(raw, no_greek=self._no_greek)
        text = self._clean_text(text)
        text = text.replace("\r", "\n")
        text = text.replace("\t", "    ")
        return text.strip()

    # ==================================================================
    # Treasury format (magic 0x36) — Treasury of Scripture Knowledge
    # ==================================================================

    def _build_treasury_index(self):
        """Build index for Treasury of Scripture Knowledge format (magic 0x36).

        Scans for ``\\r\\rBook Chapter:Verse`` section headers, normalizes
        book names via canon resolver, and populates ``_entries``,
        ``_volume_starts``, and ``_book_boundaries``.

        The file has a binary index table from ~0x300 to ~0xE39000.
        Real content starts after that with ``\\r\\r`` + verse reference
        headers.
        """
        import re as _tre_re

        # Regex: \r\r followed by verse reference like "Genesis 1:1" or "1 John 1:1"
        _VERSE_SECTION_RE = _tre_re.compile(
            rb"\r\r((?:[1-4] )?[A-Z][a-zA-Z]*(?:\s+[A-Z][a-zA-Z]*)*)\s+(\d+):(\d+)"
        )

        # Scan from 1 MB in to skip header + binary index area.
        # The real content for this module starts around 0xE39000.
        scan_start = 0x100000

        raw_sections: list[tuple[int, str, int, int]] = []  # (offset, book, ch, vs)
        seen_books: set[str] = set()
        first_book_offsets: dict[str, int] = {}
        last_book_offsets: dict[str, int] = {}

        for m in _VERSE_SECTION_RE.finditer(self.mm, scan_start):
            offset = m.start()
            book_raw = m.group(1).decode("latin-1", errors="replace")
            chapter = int(m.group(2))
            verse = int(m.group(3))

            book = _normalize_book(book_raw)
            if book is None:
                continue

            # Track book boundaries
            if book not in seen_books:
                seen_books.add(book)
                first_book_offsets[book] = offset
            last_book_offsets[book] = offset

            raw_sections.append((offset, book, chapter, verse))

        if not raw_sections:
            return

        # Build entries: (book, chapter, verse_start, verse_end, offset)
        for offset, book, chapter, verse in raw_sections:
            self._entries.append((book, chapter, verse, verse, offset))

        # Build volume_starts (sorted by offset)
        volumes = [(off, book) for book, off in first_book_offsets.items()]
        volumes.sort(key=lambda x: x[0])
        self._volume_starts = volumes

        # Build book_boundaries
        vol_list = [(b, o) for o, b in volumes]
        for i, (book, start) in enumerate(vol_list):
            if i + 1 < len(vol_list):
                end = first_book_offsets.get(vol_list[i + 1][0], self.size)
            else:
                end = self.size
            self._book_boundaries[book] = (start, end)

    def _read_treasury_section(self, start_offset: int) -> str:
        """Read Treasury section from ``\\r\\rBook Ch:Vs`` header to next section.

        The body text runs from after the ``\\r\\r`` at *start_offset*
        to just before the next ``\\r\\r`` that starts a new verse section.
        Content is decoded as Latin-1.

        For the final section (no next verse header), truncates at the
        first null byte to avoid reading the trailing binary index region.
        """
        import re as _tre_read_re

        _NEXT_SECTION_RE = _tre_read_re.compile(
            rb"\r\r((?:[1-4] )?[A-Z][a-zA-Z]*(?:\s+[A-Z][a-zA-Z]*)*)\s+(\d+):(\d+)"
        )

        # Body starts after the \r\r at start_offset
        body_start = start_offset + 2

        # Find the next section boundary
        next_match = _NEXT_SECTION_RE.search(self.mm, body_start)
        if next_match:
            body_end = next_match.start()
        else:
            # Last section: find first null byte to avoid binary tail
            null_pos = self.mm.find(b"\x00", body_start)
            if null_pos != -1 and null_pos - body_start < 0x100000:
                body_end = null_pos
            else:
                # Fallback: cap at 1 MB past body start
                body_end = min(body_start + 0x100000, self.size)

        raw = self.mm[body_start:body_end]
        text = decode(raw, no_greek=self._no_greek)
        text = self._clean_text(text)
        text = text.replace("\r", "\n")
        text = text.replace("\t", "    ")
        return text.strip()

    def _read_section_hermeneia(self, start_offset: int, is_intro: bool = False) -> str:
        """Hermeneia: read commentary text until next structured section header.

        Break conditions are deliberately narrow: ``[`` bracket markers,
        5-\\r volume boundaries, and inline book references
        (e.g. "John 3:1-21").  ``\\r\\r\\r`` (3-CR) patterns are
        subsection separators between header and body -- they must NOT
        cause a break.  Digit-starting lines and uppercase-letter lines
        are sub-section markers within the same commentary section and
        must NOT cause a break either.

        For non-verse sections (is_intro=True), bracket markers are
        skipped since introductions/excursuses often contain page
        references that do not represent section boundaries.
        """
        DASH = rb"[-\x96\x97\xd0]"
        inline_ref_re = re.compile(
            rb"([A-Z][a-z]+)\s+(\d+):(\d+)(?:\s*" + DASH + rb"\s*(\d+))?"
        )
        inline_books = {
            b"Matthew",
            b"Mark",
            b"Luke",
            b"John",
            b"Acts",
            b"Romans",
            b"Corinthians",
            b"Galatians",
            b"Ephesians",
            b"Philippians",
            b"Colossians",
            b"Thessalonians",
            b"Timothy",
            b"Titus",
            b"Philemon",
            b"Hebrews",
            b"James",
            b"Peter",
            b"Jude",
            b"Revelation",
            b"Genesis",
            b"Exodus",
            b"Leviticus",
            b"Numbers",
            b"Deuteronomy",
            b"Joshua",
            b"Judges",
            b"Ruth",
            b"Samuel",
            b"Kings",
            b"Chronicles",
            b"Ezra",
            b"Nehemiah",
            b"Esther",
            b"Job",
            b"Psalms",
            b"Proverbs",
            b"Ecclesiastes",
            b"Song",
            b"Isaiah",
            b"Jeremiah",
            b"Lamentations",
            b"Ezekiel",
            b"Daniel",
            b"Hosea",
            b"Joel",
            b"Amos",
            b"Obadiah",
            b"Jonah",
            b"Micah",
            b"Nahum",
            b"Habakkuk",
            b"Zephaniah",
            b"Haggai",
            b"Zechariah",
            b"Malachi",
        }

        end_offset = start_offset + 2
        while end_offset < self.size:
            next_pos = self.mm.find(b"\r\r", end_offset)
            if next_pos == -1:
                end_offset = self.size
                break

            after_rr = self.mm[next_pos + 2 : min(self.size, next_pos + 8)]
            is_break = False

            # Skip tab-indented sub-entries (same as scanner)
            if after_rr[:1] == b"\t":
                end_offset = next_pos + 2
                continue

            # Ü-prefixed section headers (Hermeneia) — break here
            if after_rr[:1] == b"\xdc":
                is_break = True
            # Bracket markers like [p. XX] — but not [vol. title pages.
            # Skip bracket breaks for non-verse sections (is_intro) because
            # introductions/excursuses contain page refs that are not real
            # section boundaries.
            elif after_rr[:1] == b"[" and not after_rr[:5].startswith(b"[vol."):
                if not is_intro:
                    is_break = True
            # 5-\r boundary (volume title page)
            elif after_rr[:3] == b"\r\r\r":
                is_break = True
            else:
                # Check if the first line STARTS with an inline book
                # reference (e.g. "John 3:1-21").  Use match() anchored
                # at the start so sub-section titles like
                # "9. Jesus and Nicodemus John 3:1-21" do NOT break.
                first_line_end = self.mm.find(b"\r", next_pos + 2)
                if first_line_end == -1:
                    first_line_end = min(self.size, next_pos + 256)
                first_line = self.mm[next_pos + 2 : first_line_end]
                first_line_stripped = first_line.lstrip(b" \t")
                match = inline_ref_re.match(first_line_stripped)
                if match and match.group(1) in inline_books:
                    is_break = True

            if is_break:
                end_offset = next_pos
                break

            end_offset = next_pos + 2

        raw = self.mm[start_offset + 2 : end_offset]
        text = decode(raw, no_greek=self._no_greek)
        text = self._clean_text(text)
        text = text.replace("\r", "\n")
        text = text.replace("\t", "    ")

        return text.strip()

    def _read_section_ja_nt2(self, start_offset: int) -> str:
        """JA NT-2: read annotation text until next \\r\\t section/verse boundary."""
        end_offset = start_offset + 2
        while end_offset < self.size:
            next_pos = self.mm.find(b"\r\t", end_offset)
            if next_pos == -1:
                end_offset = self.size
                break

            after_ct = self.mm[next_pos + 2 : min(self.size, next_pos + 40)]
            is_break = False

            # Break on section headers: "CH.VERSE: Title" or "[p. XX]"
            if after_ct[:1] == b"[":
                is_break = True
            elif len(after_ct) >= 1 and after_ct[:1].isdigit():
                # Check if it starts with "CH.VERSE:" (section header)
                text_sample = after_ct[:20].decode("latin-1", errors="replace")
                if re.match(r"^\d+\.\d+\s*:", text_sample):
                    is_break = True
            elif (
                after_ct[:1] == b"." and len(after_ct) >= 2 and after_ct[1:2].isdigit()
            ):
                # .VERSE: format
                text_sample = after_ct[:20].decode("latin-1", errors="replace")
                if re.match(r"^\.\d+\s*:", text_sample):
                    is_break = True

            if is_break:
                end_offset = next_pos
                break

            end_offset = next_pos + 2

        raw = self.mm[start_offset + 2 : end_offset]
        text = decode(raw, no_greek=self._no_greek)
        text = self._clean_text(text)
        text = text.replace("\r", "\n")
        text = text.replace("\t", "    ")

        return text.strip()

    def _read_raw_section(self, offset: int, max_len: int = 5000) -> str:
        """Read raw text from offset for fallback."""
        end = min(self.size, offset + max_len)
        raw = self.mm[offset:end]
        text = decode(raw, no_greek=self._no_greek)
        text = self._clean_text(text)
        text = text.replace("\r", "\n")
        return text.strip()

    def list_books(self) -> list:
        """Return list of books with indexed entry counts."""
        result = []
        for book in sorted(self._book_boundaries):
            count = sum(1 for e in self._entries if e[0] == book)
            result.append((book, count))
        return result

    def list_chapter_sections(self, book: str, chapter: int) -> list[dict]:
        """Return all section entries for a specific book and chapter.

        Args:
            book: Canonical book name (e.g. 'Genesis', 'Matthew')
            chapter: Chapter number (1-based)

        Returns:
            List of dicts with keys:
                ref: verse reference string (e.g. 'Genesis 1:1-2', 'Genesis 1:3')
                verse_start: int
                verse_end: int
                offset: int (file offset for _read_section())
                heading: str or None (first ~120 chars of section text, extracted
                         as a heading)

            Returns empty list if no sections found for that book+chapter.
        """
        if self._format == "generic":
            # Match module title as the "book" name
            module_title = self.title.strip() or self.abbrev
            if book.strip().lower() != module_title.lower():
                return []
            entries = [e for e in self._entries if e[1] == chapter]
        else:
            book = _normalize_book(book)
            entries = [e for e in self._entries if e[0] == book and e[1] == chapter]

        # Sort by offset (5th element in entry tuple)
        entries.sort(key=lambda e: e[4])

        result = []
        for e_book, e_ch, vs_start, vs_end, offset in entries:
            if self._format == "generic":
                ref = f"{e_book} section {e_ch}"
            elif vs_start == vs_end:
                ref = f"{e_book} {e_ch}:{vs_start}"
            else:
                ref = f"{e_book} {e_ch}:{vs_start}-{vs_end}"

            heading = None
            try:
                text = self._read_section(offset)
                first_line = text.split("\n")[0].strip()
                if first_line:
                    heading = first_line[:120]
            except Exception:
                pass

            result.append(
                {
                    "ref": ref,
                    "verse_start": vs_start,
                    "verse_end": vs_end,
                    "offset": offset,
                    "heading": heading,
                }
            )

        return result

    def list_sections(self) -> list[tuple[int, str]]:
        """Return list of (section_number, first_line_title) for type-6 generic modules.

        Returns empty list for non-generic (verse-based) commentary modules.
        """
        if self._format != "generic":
            return []
        result = []
        for entry in self._entries:
            section_num = entry[1]
            try:
                text = self._read_section_generic(entry[4])
                first_line = text.split("\n")[0].strip()[:100]
                title = first_line if first_line else "(no title)"
            except Exception:
                title = "(unreadable)"
            result.append((section_num, title))
        return result

    def get_section_text(self, section_number: int) -> str:
        """Return the full text for a numbered section in a type-6 generic module.

        Returns empty string if the section is not found or the module is
        not a generic (type-6) tool.
        """
        if self._format != "generic":
            return ""
        for entry in self._entries:
            if entry[1] == section_number:
                return self._read_section_generic(entry[4])
        return ""

    def list_non_verse_sections(self) -> list:
        """Return list of non-verse sections (introductions, excursuses, etc.).

        Each entry is (book, offset) where offset points to the section start.
        These are indexed with chapter=0, verse=0 markers.
        """
        result = []
        for entry in self._entries:
            if entry[1] == 0:
                result.append((entry[0], entry[4]))
        return result

    @property
    def format(self) -> str:
        """Return the detected commentary format."""
        return self._format or "unknown"

    @property
    def _no_greek(self) -> bool:
        """True if this module is known to contain no Greek text."""
        return self.abbrev in self._NO_GREEK_ABBREVS

    @property
    def skipped(self) -> bool:
        """Return True if this module was skipped as unsupported."""
        return self._skipped

    def close(self):
        self._entries = []
        self._book_boundaries = {}
        self._volume_starts = []
        super().close()


# ---------------------------------------------------------------------------
# Utility: extract page references from commentary/lexicon text
# ---------------------------------------------------------------------------

# Patterns observed across Accordance modules:
#   Hermeneia:         [vol. 66, p. 474]   [vol. 64B, p. 218]
#   TDNT:              [Vol. IV, p. 763]
#   Catholic SB-3:     [p. 1553]
#   Clarke:            p. 418  (often unbracketed in body text)
#   Sacra Pagina:      [Rom, p. 80]
#   JA NT-2:           p. 658    or  [p. 302]
#   IVP-NB:            [p. 58]

_PAGE_REF_RE = re.compile(
    r"(?:\[[Vv]ol\.?\s*(?P<vol>[IVXivx\dA-Za-z]+),\s*)?"
    r"(?:\[)?\bp\.\s*(?P<page>\d{2,5})(?:\])?"
)


def extract_page_refs(text: str, max_results: int = 10) -> list[tuple[str | None, int]]:
    """Extract (volume, page_number) tuples from commentary or lexicon text.

    Returns up to *max_results* unique page references found in *text*.
    Volume is ``None`` when the module doesn't include volume information.
    Works on the raw string returned by ``CommentaryModule.get_section()``
    and ``LexiconModule.lookup()``.

    Example:
        >>> refs = extract_page_refs(text)
        >>> for vol, pg in refs:
        ...     print(f"Vol. {vol}, p. {pg}" if vol else f"p. {pg}")
    """
    seen: set[tuple[str | None, int]] = set()
    results: list[tuple[str | None, int]] = []

    for m in _PAGE_REF_RE.finditer(text):
        vol = m.group("vol")
        page = int(m.group("page"))
        key = (vol, page)
        if key not in seen:
            seen.add(key)
            results.append((vol, page))
            if len(results) >= max_results:
                break

    return results
