import json
import struct
from pathlib import Path
from typing import Iterator, List, Optional

from .module import AccordanceModule
from .canon import (
    CANON_PROTESTANT,
    BOOKS_BY_OSIS,
    resolve_book,
    canon_by_name,
    _VERSE_COUNTS,
)
from .constants import _MACROMAN_TO_UNICODE, _BUILTIN_WORDS
from .verse_offset import VerseOffsetResolver


def _prep_macroman_bytes(data: bytes) -> bytes:
    """Replace MacRoman bytes with their UTF-8 encoded Unicode equivalents.

    Converts a byte string containing MacRoman-encoded characters into
    valid UTF-8 that can be decoded without replacement characters.
    Bytes not in _MACROMAN_TO_UNICODE pass through unchanged.

    NOTE: This intentionally leaves Accordance transliteration markers
    (0xA7=ß/final-sigma, 0xC0=À, 0xCF=Ï, 0xEE=î, 0xF6=ö, 0xA8=¨, 0xB7=·)
    as-is — they are handled by encoding.py's _decode_greek / _decode_hebrew
    paths, not by this function.
    """
    result = bytearray()
    for b in data:
        uc = _MACROMAN_TO_UNICODE.get(b)
        if uc is not None:
            result.extend(uc.encode("utf-8"))
        else:
            result.append(b)
    return bytes(result)


# ── Per-module Strong's concordance configuration ───────────────────────
# Each module has its own KT1, Pool, block layout, and ktstart mapping.
# Format: [kt1_start, pool_start, pool_swap, ktstart_base, ktstart_slope,
#          [(block_id, count), ...],
#          {block_id: override_ktstart, ...}]  (optional per-block ktstart overrides)

_STRONGS_CONFIG = {
    "ESVS": [
        0x040F9F,  # kt1_start
        0x075ADC,  # pool_start
        False,  # pool_swap (False = [u16_lo BE][block_id BE])
        5276,  # ktstart_base
        108,  # ktstart_slope
        [
            (2, 2084),
            (3, 2805),
            (4, 2630),
            (5, 2293),
            (6, 2569),
            (7, 2838),
            (8, 2182),
            (9, 2148),
            (10, 2186),
            (11, 2703),
            (12, 2625),
            (13, 2480),
            (14, 1599),
        ],
        {2: 3316},  # block 2 = NT Greek, maps to G-prefix entries
    ],
    "KJVS": [
        0x037BCA,  # kt1_start
        0x06B29C,  # pool_start
        True,  # pool_swap (True = [block_id BE][u16_lo BE])
        5332,  # ktstart_base (derived from H0430 at KT1[5940] in block 3)
        108,  # ktstart_slope
        [
            (2, 1552),
            (3, 2111),
            (4, 2137),
            (5, 1874),
            (6, 2623),
            (7, 3040),
            (8, 1965),
            (9, 1674),
            (10, 1938),
            (11, 2390),
            (12, 2350),
            (13, 2154),
            (14, 371),
        ],
        {2: 3316},  # block 2 = NT Greek (tentative)
    ],
    "NAS95S": [
        0x045ACC,  # kt1_start
        0x073726,  # pool_start
        True,  # pool_swap (True = [block_id BE][u16_lo BE])
        5276,  # ktstart_base (NOT YET VERIFIED)
        108,  # ktstart_slope
        [
            (2, 1804),
            (3, 2298),
            (4, 2296),
            (5, 2104),
            (6, 2224),
            (7, 2715),
            (8, 2331),
            (9, 1901),
            (10, 1997),
            (11, 2368),
            (12, 2410),
            (13, 2191),
            (14, 1840),
            (15, 295),
        ],
        {},
    ],
}


def _get_strongs_config(module_abbr: str):
    """Return Strong's config for a module, or None if not available."""
    return _STRONGS_CONFIG.get(module_abbr)


def _kt1_start_for_block(block: int, base: int = 5276, slope: int = 108) -> int:
    """Return the KT1 base index for a given pool block.

    Default formula (ESVS): ktstart[B] = 5276 + 108 * B
    Verified: ktstart[3]=5600 (H0430 at block3/pos284), ktstart[6]=5924 (H0776 at block6/pos306).
    """
    return base + slope * block


ANCHORS_DIR = Path(__file__).resolve().parent.parent / "known_offsets"

# Book alias resolution and reference parsing: moved to .canon

# Verse count data now in .canon module; accessed via self._verse_counts


def parse_reference(ref: str) -> tuple[str, int, int] | None:
    """Parse a Bible reference via canon.py. Returns (book_name, chapter, verse) or None."""
    from .canon import parse_reference as _canon_parse_ref

    result = _canon_parse_ref(ref, canon="protestant")
    if result is None:
        return None
    book_obj = BOOKS_BY_OSIS.get(result.book_osis)
    if book_obj is None:
        return None
    return (
        book_obj.name,
        result.chapter,
        result.verse if result.verse is not None else 1,
    )


def _normalize_book_name(book: str, known_books: set) -> str | None:
    """Resolve a book name via canon.py. Returns canonical spaced name or None."""
    resolved = resolve_book(book)
    if resolved is None:
        return None
    if resolved.name not in known_books:
        return None
    return resolved.name


class TextModule(AccordanceModule):
    # Lazily-loaded set of common English words for footnote-marker detection
    _WORD_SET: set[str] | None = None
    # 4-letter prefixes from _WORD_SET — used to guard against false positives
    # when stripping footnote markers or splitting concatenated words.
    _WORD_PREFIX_4: set[str] | None = None

    @classmethod
    def _load_word_set(cls) -> set[str]:
        """Load a set of common English words for footnote-marker stripping.
        Uses the system dictionary if available, otherwise falls back to a
        built-in set of common biblical English words."""
        if cls._WORD_SET is not None:
            return cls._WORD_SET

        word_set = set()

        # Try system dictionary first
        try:
            with open("/usr/share/dict/words") as f:
                for line in f:
                    w = line.strip()
                    # Lowercase alpha-only words, 2-12 chars
                    if 2 <= len(w) <= 12 and w.isalpha() and w.islower():
                        word_set.add(w)
        except (FileNotFoundError, PermissionError):
            pass

        # If system dict unavailable or empty, use built-in fallback
        if not word_set:
            word_set = set(cls._BUILTIN_WORDS)

        cls._WORD_SET = word_set

        # Build 4-letter prefix set for false-positive guards
        _prefixes = set()
        for w in word_set:
            if len(w) >= 4:
                _prefixes.add(w[:4])
        cls._WORD_PREFIX_4 = _prefixes

        return word_set

    # Built-in fallback: common English words (used when system dict unavailable)
    _BUILTIN_WORDS: frozenset[str] = _BUILTIN_WORDS

    def __init__(
        self,
        filepath: str,
        module_abbr: str | None = None,
        page_table_offset: int | None = None,
        canon: str = "protestant",
    ):
        super().__init__(filepath)
        self.module_abbr = module_abbr
        self._canon_obj = canon_by_name(canon)
        self._books = [book.name for book in self._canon_obj.books]

        # Build verse count lookup keyed by spaced canonical name.
        # Maps book name -> tuple(verse_count_per_chapter)
        self._verse_counts: dict[str, tuple] = {}
        for book_obj in self._canon_obj.books:
            if book_obj.osis in self._canon_obj.last_verse:
                self._verse_counts[book_obj.name] = self._canon_obj.last_verse[
                    book_obj.osis
                ]

        self._anchors_map: dict[str, int] = {}
        self._book_anchors: dict[str, int] = {}
        abbr = self.module_abbr or self.abbrev
        self._is_hebrew = abbr in TextModule._HEBREW_MODULES
        self._is_greek = abbr in TextModule._GREEK_MODULES
        self._page_table_offset = page_table_offset
        self._page_table_format: str | None = (
            None  # 'u24' or 'u16' — set by _detect_page_table()
        )
        self._offset_resolver = VerseOffsetResolver()

        # ── LXX1: override Protestant canon with LXX book order ──
        if abbr == "LXX1":
            self._books = list(TextModule._LXX_BOOK_ORDER)
            self._verse_counts = {}
            _name_to_osis = {b.name: b.osis for b in BOOKS_BY_OSIS.values()}
            for name in TextModule._LXX_BOOK_ORDER:
                o = _name_to_osis.get(name)
                self._verse_counts[name] = _VERSE_COUNTS.get(o, ()) if o else ()
            self._page_table_offset = TextModule._LXX_PT_OFFSET
            self._page_table_format = "u24"
        if (
            self._page_table_offset is None
            and not self._is_hebrew
            and not self._is_greek
        ):
            self._page_table_offset = self._detect_page_table()
        # Calibrate auto-detected page tables: find Gen 1:1 entry index and
        # count text entries for proportional verse mapping.
        if (
            self._page_table_offset is not None
            and abbr not in self._KNOWN_PT_OFFSETS
            and not self._is_hebrew
            and not self._is_greek
            and abbr != "LXX1"
        ):
            self._calibrate_page_table()
        if self._is_greek:
            is_full_canon = abbr in TextModule._FULL_CANON_GREEK_MODULES
            if not is_full_canon:
                # NT-only books for Greek NT modules -- must narrow _books
                # and _verse_counts BEFORE _scan_greek_book_markers() so that
                # _greek_book_vt_start uses NT-only indices consistent with
                # the actual verse table size.
                nt_books = [
                    book.name
                    for book in self._canon_obj.books
                    if book.testament == "nt"
                ]
                self._books = nt_books
                self._verse_counts = {}
                for book_obj in self._canon_obj.books:
                    if (
                        book_obj.testament == "nt"
                        and book_obj.osis in self._canon_obj.last_verse
                    ):
                        self._verse_counts[book_obj.name] = self._canon_obj.last_verse[
                            book_obj.osis
                        ]
            # Fine verse table: only load if offset is valid AND module has one.
            # LXX1 (full-canon Greek) lacks a fine verse table; GNT-TRS also lacks it.
            abbr = self.module_abbr or self.abbrev
            vt_off = (
                self._GREEK_VT_OFFSET.get(abbr, 0)
                if isinstance(self._GREEK_VT_OFFSET, dict)
                else self._GREEK_VT_OFFSET
            )
            has_vt = vt_off > 0 and vt_off < len(self.mm) and not is_full_canon
            if has_vt:
                self._greek_vt = self._load_greek_verse_table()
                self._greek_book_map = self._scan_greek_book_markers()
            else:
                self._greek_vt = []
                # Scan markers for paragraph boundaries (used by fallback)
                self._greek_book_map = self._scan_greek_book_markers()
                if self._page_table_offset is None and not is_full_canon:
                    self._page_table_offset = self._detect_page_table()
            # ── Build per-verse VT-increment byte offset map ──
            self._build_greek_vt_offsets()
            # ── Build _book_anchors for byte-linear interpolation ──
            # Each anchor = byte offset of the first verse's first character.
            # GNT28-T: from 20 B9 20 book title markers (verified).
            # GNT-TRS: from verse-count proportion (approximate but sufficient).
            if not is_full_canon:
                if has_vt:
                    # GNT28-T/NA28-T: use per-book B9 20 09 verse-marker
                    # positions (exact text starts) as byte-linear anchors.
                    self._book_anchors = dict(self._greek_book_text_starts)
                else:
                    # GNT-TRS: calibrate anchors against known first-verse text
                    self._book_anchors = self._calibrate_gnt_trs_book_starts()
            # Build per-chapter anchors for within-book interpolation
            if (
                hasattr(self, "_book_anchors")
                and self._book_anchors
                and not is_full_canon
            ):
                self._build_greek_chapter_anchors()
        self._load_anchors()

    # Known page table offsets for common modules (verified).
    # Each entry: (offset, format) where format is 'u24' or 'u16'.
    _KNOWN_PT_OFFSETS: dict[str, tuple[int, str]] = {
        "ESVS": (0x333A0, "u24"),
        "NABRE": (0x152F8, "u24"),
        "KJVS": (0x2C870, "u16"),  # u16 BE index format (XX YY 00 00)
    }

    # Hebrew modules: (page_table_offset, gen1_entry_index) — u16_BE page×0x100 format
    # Page table at pt_offset: 4-byte entries: [u16_BE page][u16_BE sub-offset]
    # u16_BE page * 0x100 = byte offset in file. sub-offset is 0x0000 for
    # most entries (the page boundary itself locates the text).
    _HEBREW_PT = {
        "MT-ETCBC-A": (0x13000, 1055),
        "HMT-DEMO": (0x8B3E, 406),
    }

    # Module abbreviations known to use Hebrew (Accordance 8-bit) encoding
    _HEBREW_MODULES = frozenset(
        {
            "MT-ETCBC-A",
            "HMT-DEMO",
        }
    )

    # Greek modules (Accordance 8-bit Beta Code encoding, 20 B9 20 markers)
    # LXX1 is the Septuagint (full OT + Apocrypha canon — NOT NT-only)
    _GREEK_MODULES = frozenset(
        {
            "GNT28-T",
            "GNT-TRS",
            "NA28-T",
            "LXX1",
        }
    )

    # Greek modules that cover the FULL canon (not NT-only)
    _FULL_CANON_GREEK_MODULES = frozenset({"LXX1"})

    # Fine verse table offset and format for Greek .atext modules
    _GREEK_VT_OFFSET = {
        "GNT28-T": 0x430000,
        "NA28-T": 0x350000,
    }  # per-module VT offsets

    # Per-verse hardcoded anchors for Greek modules where marker-based
    # interpolation drifts (verified against known text positions).
    _GREEK_VERSE_ANCHORS: dict[str, dict[tuple[str, int, int], int]] = {
        "NA28-T": {
            ("Matthew", 5, 1): 0x37223E,  # "Ιδὼν δε τοὺς ὄχλους" (Sermon on Mount)
            ("John", 3, 16): 0x3CDF0C,  # "οὕτως γὰρ ἠγάπησεν"
            ("Romans", 8, 28): 0x411264,  # "Οἴδαμεν δε..."
            ("1 Corinthians", 13, 1): 0x4207CF,  # "Εὰν ταῖς γλώσσαις" (Love chapter)
        },
        "GNT28-T": {
            ("Matthew", 5, 1): 0x445E9D,  # "Ιδὼν δε τοὺς ὄχλους"
            ("John", 3, 16): 0x49EBB0,  # "οὕτως γὰρ ἠγάπησεν"
            ("Romans", 8, 28): 0x4DFB13,  # "Οἴδαμεν δε..."
            ("1 Corinthians", 13, 1): 0x4EEA2D,  # "Εὰν ταῖς γλώσσαις" (Love chapter)
        },
        "GNT-TRS": {
            ("Matthew", 4, 1): 0x286F2D,  # "Τότε Ἰησοῦς ἀνήχθη" (Temptation)
            ("Matthew", 5, 1): 0x287C13,  # "Ιδὼν δε τοὺς ὄχλους" (Sermon on Mount)
            ("John", 3, 16): 0x2E214C,  # "Οὕτω γὰρ ἠγάπησεν"
            ("Romans", 8, 28): 0x323C29,  # "Οἴδαμεν δε..."
        },
    }

    # ── LXX1 page-table config ───────────────────────────────────────────
    _LXX_PT_OFFSET = 0xECE78  # u24 BE page table (00 XX YY ZZ entries)
    _LXX_GEN1_IDX = 2040  # Gen 1:1 at page-table entry 2040
    _LXX_BOOK_ORDER = [
        "Genesis",
        "Exodus",
        "Leviticus",
        "Numbers",
        "Deuteronomy",
        "Joshua",
        "Judges",
        "Ruth",
        "1 Samuel",
        "2 Samuel",
        "1 Kings",
        "2 Kings",
        "1 Chronicles",
        "2 Chronicles",
        "1 Esdras",
        "2 Esdras",
        "Esther",
        "Judith",
        "Tobit",
        "1 Maccabees",
        "2 Maccabees",
        "3 Maccabees",
        "4 Maccabees",
        "Psalms",
        "Odes",
        "Proverbs",
        "Ecclesiastes",
        "Song of Solomon",
        "Job",
        "Wisdom",
        "Sirach",
        "Hosea",
        "Amos",
        "Micah",
        "Joel",
        "Obadiah",
        "Jonah",
        "Nahum",
        "Habakkuk",
        "Zephaniah",
        "Haggai",
        "Zechariah",
        "Malachi",
        "Isaiah",
        "Jeremiah",
        "Baruch",
        "Lamentations",
        "Letter of Jeremiah",
        "Ezekiel",
        "Daniel",
        "Susanna",
        "Bel and the Dragon",
        "Prayer of Manasseh",
        "Psalm 151",
    ]

    # Language-specific Genesis 1:1 search terms.
    # Each tuple: (search_bytes, context_terms_for_verification)
    # Context terms are checked sequentially: first two must both appear
    # within 200 bytes of the search hit; third is optional (earth/heaven).
    _GEN1_SEARCH_TERMS: list[tuple[bytes, tuple[bytes, ...]]] = [
        (b"beginning", (b"God", b"heaven", b"earth")),  # English (most translations)
        (
            b"first",
            (b"God", b"heaven", b"earth"),
        ),  # English (BBE: "At the first God made...")
        (b"principio", (b"Dios", b"cielos", b"tierra")),  # Spanish (LBLA, NBLH, RVR09S)
        (b"Anfang", (b"Gott", b"Himmel", b"Erde")),  # German (ELBER05)
        (
            "起初".encode("utf-8"),
            ("神".encode("utf-8"), "天地".encode("utf-8")),
        ),  # Chinese (CUVS)
        (
            "început".encode("utf-8"),
            (b"Dumnezeu", b"cerurile", b"pamant"),
        ),  # Romanian (VDC2014)
    ]

    # Language-specific John 3:16 search terms (second calibration anchor).
    # Used to improve proportional mapping by providing a mid-canon reference.
    _JOHN316_SEARCH_TERMS: list[tuple[bytes, tuple[bytes, ...]]] = [
        (
            b"For God so loved the world",
            (b"only begotten", b"perish"),
        ),  # ASV, KJV, KJVS
        (b"God so loved the world", (b"only Son", b"perish")),  # BBE, ESV, LSV, NETS
        (b"God loved the world", (b"only Son", b"eternal life")),  # CEB, MESSAGE
        (
            b"Porque de tal manera am",
            (b"unig", b"Hijo", b"perezca"),
        ),  # Spanish: LBLA, RVR09S
        (b"Porque tanto am", (b"unig", b"Hijo", b"pierda")),  # Spanish: NBLH
        (b"Also hat Gott die Welt geliebt", (b"Sohn", b"verloren")),  # German: ELBER05
        (
            "神愛世人".encode("utf-8"),
            ("獨生子".encode("utf-8"), "滅亡".encode("utf-8")),
        ),  # Chinese: CUVS
    ]

    def _find_genesis_1_1(self) -> int | None:
        """Find the byte offset of Genesis 1:1 text using language-specific search terms.

        Returns the byte position of the Genesis 1:1 verse text, or None.
        """
        size = len(self.mm)
        for search_term, ctx_terms in self._GEN1_SEARCH_TERMS:
            pos = max(0x400000, size // 3)  # Search from ~1/3 into file (past headers)
            while 0 < pos < size:
                pos = self.mm.find(search_term, pos)
                if pos < 0:
                    break
                # Verify context: all required terms within 200 bytes
                nearby = bytes(self.mm[pos : pos + 200])
                # First context term is the primary one (e.g., 'God', 'Dios', etc.)
                if ctx_terms[0] not in nearby:
                    pos += 1
                    continue
                # At least one of the remaining terms must appear
                remaining = ctx_terms[1:]
                if not any(ct in nearby for ct in remaining):
                    pos += 1
                    continue
                return pos
            # Fallback: search from beginning (for smaller files)
            pos = 0
            while 0 <= pos < size:
                pos = self.mm.find(search_term, pos)
                if pos < 0:
                    break
                if pos > 0x100000:
                    nearby = bytes(self.mm[pos : pos + 200])
                    if ctx_terms[0] in nearby and any(
                        ct in nearby for ct in ctx_terms[1:]
                    ):
                        return pos
                    pos += 1
                else:
                    pos += 1
        return None

    def _find_john_3_16(self) -> int | None:
        """Find the byte offset of John 3:16 text using language-specific search terms.

        Returns the byte position of the John 3:16 verse text, or None.
        Searches from the end of the file backward (John is near the end).
        """
        size = len(self.mm)
        for search_term, ctx_terms in self._JOHN316_SEARCH_TERMS:
            # Search from ~70% into file (John is in the NT, last ~25% of canon)
            pos = max(0x400000, size * 2 // 3)
            while 0 < pos < size:
                pos = self.mm.find(search_term, pos)
                if pos < 0:
                    break
                # Verify context: at least one term within 200 bytes
                nearby = bytes(self.mm[pos : pos + 300])
                if any(ct in nearby for ct in ctx_terms):
                    return pos
                pos += 1
            # Fallback: search from beginning
            pos = 0
            while 0 <= pos < size:
                pos = self.mm.find(search_term, pos)
                if pos < 0:
                    break
                if pos > 0x100000:
                    nearby = bytes(self.mm[pos : pos + 300])
                    if any(ct in nearby for ct in ctx_terms):
                        return pos
                pos += 1
        return None

    def _detect_page_table(self) -> int | None:
        """Auto-detect the page table for ANY module.

        Finds Gen 1:1 text using language-specific terms, then locates the
        page table (u24 BE or u16 BE format) whose entries bracket the Gen 1:1
        position.  Falls back to known offsets for ESVS and NABRE.
        """
        abbr = self.module_abbr or self.abbrev
        known = self._KNOWN_PT_OFFSETS.get(abbr)
        if known is not None:
            self._page_table_format = known[1]
            return known[0]

        # Step 1: Find Genesis 1:1 text
        gen1 = self._find_genesis_1_1()
        if gen1 is None:
            return None

        # Step 2: Try u24 BE format (00 XX YY ZZ entries)
        result = self._detect_u24_table(gen1)
        if result is not None:
            self._page_table_format = "u24"
            return result

        # Step 3: Try u16 BE format (XX YY 00 00 entries, page × 0x100)
        result = self._detect_u16_table(gen1)
        if result is not None:
            self._page_table_format = "u16"
            return result

        return None

    def _detect_u24_table(self, gen1: int) -> int | None:
        """Detect a u24 BE page table (00 XX YY ZZ format) bracketing gen1."""
        size = len(self.mm)
        stride = 0x1000
        best_off = None
        best_score = float("-inf")
        MIN_COUNT = 200

        for off in range(0x10000, size - 50000, stride):
            if off + 16 > size:
                break
            if self.mm[off] != 0x00:
                continue

            v0 = (self.mm[off + 1] << 16) | (self.mm[off + 2] << 8) | self.mm[off + 3]
            if not (0x100000 < v0 < size):
                continue

            # Count entries and find the bracket containing gen1
            count = 0
            gen1_idx = -1
            for i in range(50000):
                o2 = off + i * 4
                if o2 + 4 > size:
                    break
                if self.mm[o2] != 0x00:
                    break
                v = (self.mm[o2 + 1] << 16) | (self.mm[o2 + 2] << 8) | self.mm[o2 + 3]
                if v == 0 and count > MIN_COUNT:
                    break
                if v <= gen1:
                    gen1_idx = i
                count += 1

            if gen1_idx < 0 or gen1_idx >= count - 1:
                continue

            cur_v = (
                (self.mm[off + gen1_idx * 4 + 1] << 16)
                | (self.mm[off + gen1_idx * 4 + 2] << 8)
                | self.mm[off + gen1_idx * 4 + 3]
            )
            next_v = (
                (self.mm[off + (gen1_idx + 1) * 4 + 1] << 16)
                | (self.mm[off + (gen1_idx + 1) * 4 + 2] << 8)
                | self.mm[off + (gen1_idx + 1) * 4 + 3]
            )

            if not (cur_v <= gen1 < next_v):
                continue

            bracket_size = next_v - cur_v
            if bracket_size > 0x5000:  # too loose — probably wrong table
                continue

            # Score: prefer more entries (wider span) and tighter brackets.
            # Use count × 1000 - bracket to keep score positive for useful tables.
            score = count * 1000 - bracket_size
            if score > best_score and count >= MIN_COUNT:
                best_off = off
                best_score = score

        return best_off

    def _detect_u16_table(self, gen1: int) -> int | None:
        """Detect a u16 BE page table (XX YY 00 00 format, page × 0x100) bracketing gen1."""
        size = len(self.mm)
        best_off = None
        best_score = float("-inf")
        MIN_COUNT = 200
        MAX_SUB = 0xFFF  # sub-offset must be small (typically 0x0000)

        for off in range(0x10000, min(size - 50000, 0x400000), 0x1000):
            if off + 4 > size:
                break
            # First entry: u16 BE page, sub-offset must be small
            page = (self.mm[off] << 8) | self.mm[off + 1]
            addr = page * 0x100
            sub = (self.mm[off + 2] << 8) | self.mm[off + 3]
            if not (0x100000 < addr < size):
                continue
            if sub > MAX_SUB:
                continue

            # Count entries in this table
            count = 0
            gen1_idx = -1
            for i in range(50000):
                o2 = off + i * 4
                if o2 + 4 > size:
                    break
                p = (self.mm[o2] << 8) | self.mm[o2 + 1]
                s = (self.mm[o2 + 2] << 8) | self.mm[o2 + 3]
                # End of table: zero page or sub-offset too large
                if p == 0 and s == 0 and count > MIN_COUNT:
                    break
                if s > MAX_SUB and count > 100:
                    break
                a = p * 0x100
                if a <= gen1:
                    gen1_idx = i
                count += 1

            if gen1_idx < 0 or gen1_idx >= count - 1:
                continue

            cur_a = (
                (self.mm[off + gen1_idx * 4] << 8) | self.mm[off + gen1_idx * 4 + 1]
            ) * 0x100
            nxt_a = (
                (self.mm[off + (gen1_idx + 1) * 4] << 8)
                | self.mm[off + (gen1_idx + 1) * 4 + 1]
            ) * 0x100

            if not (cur_a <= gen1 < nxt_a):
                continue

            # For u16 tables, bracket can be large (pages cover many verses).
            # Score: prefer more entries.
            score = count * 1000
            if score > best_score and count >= MIN_COUNT:
                best_off = off
                best_score = score

        return best_off

    def _calibrate_page_table(self):
        """After auto-detecting the page table, locate Gen 1:1 within it.

        Finds the page table entry index that brackets Gen 1:1 and counts
        the total number of text entries (page > 0x100000) from Gen 1:1
        to the end.  Also locates John 3:16 as a second calibration anchor
        for piecewise-proportional verse mapping.  Stores self._gen1_pt_index,
        self._pt_entry_count, self._john316_pt_index, and self._john316_cum.
        """
        gen1 = self._find_genesis_1_1()
        if gen1 is None:
            self._gen1_pt_index: int | None = None
            self._pt_entry_count: int = 0
            self._gen1_pos: int = 0
            self._john316_pt_index: int | None = None
            self._john316_cum: int = 0
            return

        self._gen1_pos = gen1
        pt = self._page_table_offset
        is_u16 = self._page_table_format == "u16"
        fsize = len(self.mm)

        gen1_idx = None
        john316_idx = None
        count = 0
        last_text_idx = 0
        first_text_idx = None
        prev_page = 0

        # Find John 3:16 text position (second calibration anchor)
        john316_pos = self._find_john_3_16()

        for idx in range(50000):
            eoff = pt + idx * 4
            if eoff + 4 > fsize:
                break
            if is_u16:
                u16_val = struct.unpack_from(">H", self.mm, eoff)[0]
                sub = struct.unpack_from(">H", self.mm, eoff + 2)[0]
                page_start = u16_val * 0x100
                if u16_val == 0 and sub == 0 and count > 200:
                    break
                if sub > 0xFFF and count > 100:
                    break
            else:
                if self.mm[eoff] != 0x00:
                    if count > 200:
                        break
                    count += 1
                    continue
                page_start = self._read_u24_be_after_tag(eoff)
                if page_start == 0 and count > 200:
                    break

            if page_start <= gen1:
                gen1_idx = idx
            if john316_pos is not None and page_start <= john316_pos:
                john316_idx = idx

            if page_start > 0x100000:
                if page_start >= fsize:
                    break
                if prev_page > 0x100000 and page_start < prev_page - 0x500000:
                    break
                if first_text_idx is None:
                    first_text_idx = idx
                last_text_idx = idx

            prev_page = page_start
            count += 1

        self._gen1_pt_index = gen1_idx
        self._john316_pt_index = john316_idx
        if gen1_idx is not None and last_text_idx >= gen1_idx:
            self._pt_entry_count = last_text_idx - gen1_idx + 1
        else:
            self._pt_entry_count = 0

        # Compute John 3:16 cumulative verse position in the canon
        self._john316_cum = 0
        if john316_idx is not None:
            self._build_cumulative_index()
            for bk in self._books:
                if bk == "John":
                    break
                vd = self._verse_counts.get(bk)
                if vd:
                    self._john316_cum += sum(vd)
            vdata = self._verse_counts.get("John")
            if vdata:
                self._john316_cum += (
                    sum(vdata[:2]) + 15
                )  # chapters 1+2 + verse 16 (0-based)

    def _load_anchors(self):
        abbr = self.module_abbr or self.abbrev
        anchor_file = ANCHORS_DIR / f"{abbr}.json"
        if not anchor_file.exists():
            return

        with open(anchor_file) as f:
            anchors = json.load(f)

        self._known_books = set()
        self._book_first_offset = {}
        self._book_last_offset = {}
        self._canon_anchors = []  # [(book, chapter, verse, offset)] sorted by canon order

        for a in anchors:
            self._anchors_map[a["ref"]] = a["offset"]
            pr = parse_reference(a["ref"])
            if pr:
                book, chapter, verse = pr
                self._known_books.add(book)
                # Also add anchor with short book name (e.g., "Wisdom 1:1"
                # alongside "Wisdom of Solomon 1:1")
                short_ref = f"{book} {chapter}:{verse}"
                if short_ref != a["ref"]:
                    self._anchors_map[short_ref] = a["offset"]
                # Also add the full book name from the reference string
                ref_str = a["ref"]
                space_idx = ref_str.rfind(" ")
                if space_idx > 0:
                    colon_pos = ref_str.rfind(":")
                    if colon_pos > space_idx:
                        full_book = ref_str[:space_idx]
                        if full_book != book:
                            self._known_books.add(full_book)
                self._canon_anchors.append((book, chapter, verse, a["offset"]))
                if book not in self._book_first_offset:
                    self._book_first_offset[book] = a["offset"]
                    self._book_last_offset[book] = a["offset"]
                else:
                    if a["offset"] < self._book_first_offset[book]:
                        self._book_first_offset[book] = a["offset"]
                    if a["offset"] > self._book_last_offset[book]:
                        self._book_last_offset[book] = a["offset"]

        # Sort anchors by file offset (which matches canon order in the file)
        self._canon_anchors.sort(key=lambda x: x[3])

        # Build dynamic chapter anchors for cumulative-chapter interpolation
        # Each anchor is a (book, chapter, offset) tuple. Sort by cumulative chapter
        # to create a chain spanning the entire canon with module-specific offsets.
        self._build_cumulative_index()
        self._dynamic_anchors = {}  # (book, chapter) -> offset
        for a in anchors:
            pr = parse_reference(a["ref"])
            if pr:
                book, chapter, verse = pr
                if verse == 1:  # Chapter-start anchors only
                    self._dynamic_anchors[(book, chapter)] = a["offset"]

    # ── Bible canon constants (from canon.py) ────────────────────────────────────
    _BOOKS = [book.name for book in CANON_PROTESTANT.books]

    # First Bible text page index (ESVS: PT[8357] = Gen 1:1)
    _FIRST_TEXT_PT = 8357
    _LAST_TEXT_PT = 13074

    # Known chapter→byte offset anchors (empirically verified via text search)
    # Format: (book, chapter, byte_offset_in_file)
    # Chapter 1 anchors span the entire canon; additional mid-book anchors
    # improve interpolation for books with uneven chapter sizes.
    _CHAPTER_ANCHORS = [
        ("Genesis", 1, 0xA3CEA4),
        ("Genesis", 2, 0xA3DEFC),
        ("Genesis", 5, 0xA4044A),
        ("Genesis", 6, 0xA40D3C),
        ("Genesis", 9, 0xA42DAE),
        ("Genesis", 11, 0xA443E8),
        ("Genesis", 12, 0xA44FFE),
        ("Genesis", 15, 0xA46EC2),
        ("Exodus", 1, 0xA6ADD5),
        ("Exodus", 20, 0xA7C4D6),
        ("Leviticus", 1, 0xA91BB2),
        ("Numbers", 1, 0xAB1B89),
        ("Deuteronomy", 1, 0xAD6D29),
        ("Judges", 1, 0xB10BFE),
        ("Ruth", 1, 0xB27EDD),
        ("1 Samuel", 1, 0xB2AF87),
        ("2 Samuel", 1, 0xB4949A),
        ("1 Kings", 1, 0xB623F9),
        ("2 Kings", 1, 0xB7FD7C),
        ("1 Chronicles", 1, 0xB9C7FA),
        ("2 Chronicles", 1, 0xBB5A7D),
        ("Ezra", 1, 0xBD5F5D),
        ("Nehemiah", 1, 0xBDEF96),
        ("Esther", 1, 0xBEBE93),
        ("Job", 1, 0xBF301A),
        ("Psalms", 1, 0xC09DFE),
        ("Psalms", 23, 0xC11439),
        ("Proverbs", 1, 0xC427DB),
        ("Isaiah", 1, 0xC6010C),
        ("Isaiah", 40, 0xC7AE25),
        ("Isaiah", 52, 0xC85127),
        ("Isaiah", 53, 0xC859EB),
        ("Isaiah", 54, 0xC86259),
        ("Isaiah", 55, 0xC86CCD),
        ("Isaiah", 56, 0xC874CE),
        ("Jeremiah", 1, 0xC8F185),
        ("Ezekiel", 1, 0xCC8B1D),
        ("Joshua", 1, 0xAF9550),
        ("Amos", 1, 0xD10966),
        ("Nahum", 1, 0xD1C7C2),
        ("Zechariah", 1, 0xD23705),
        ("Matthew", 1, 0xD2D60D),
        ("Mark", 1, 0xD4A4CD),
        ("Luke", 1, 0xD5C94C),
        ("John", 1, 0xD7BDE4),
        ("John", 3, 0xD7DBBE),
        ("Acts", 1, 0xD934FB),
        ("Romans", 1, 0xDE8421),
        ("Romans", 8, 0xDB746C),
        ("Revelation", 1, 0xE0069D),
        ("Revelation", 22, 0xE0E60C),
    ]

    # ESVS-specific per-verse anchors for hard-to-position verses
    # where T1 word-count interpolation drifts past the correct boundary.
    # Each entry: (book, chapter, verse, byte_offset_to_text_start)
    _ESVS_VERSE_ANCHORS = [
        ("Exodus", 20, 2, 0xA7C501),  # Long verse 1; 0xA6 fmt: A6+footnote+0xD2
        ("Isaiah", 53, 5, 0xC85C3C),  # T1 estimate ~74 bytes off; snaps to v4's 0xA6
        ("John", 3, 16, 0xD7E1D4),  # T1 overshoots; verse-16 paragraph has its own 0xA6
        ("Psalms", 23, 6, 0xC115F4),  # T1 overshoots to Psalm 24:1
        ("Malachi", 4, 6, 0xD2D56B),  # T1 drifts 4 verses within chapter
        ("Matthew", 5, 3, 0xD300ED),  # T1 returns Matt 4:19; anchor at 'Blessed'
        ("1 Corinthians", 13, 1, 0xDC6A50),  # T1 returns 1 Cor 12:14
        ("Romans", 8, 28, 0xDB812D),  # T1 returns Rom 8:26
    ]

    # KJVS-specific per-verse anchors for hard-to-position verses
    # where T1 word-count interpolation drifts past the correct boundary.
    # Each entry: (book, chapter, verse, byte_offset_to_text_start)
    _KJVS_VERSE_ANCHORS = [
        (
            "Malachi",
            4,
            6,
            0xD0097E,
        ),  # T1 snaps to Mal 3:10; anchored at 'And he shall turn'
        ("Romans", 8, 28, 0xD8FE2A),  # T1 drifts to Rom 8:26; anchored at 'And we know'
    ]

    # KJVS-specific chapter anchors (verified via text search — see scripts/find_kjvs_anchors.py)
    # Each entry: (book, chapter, byte_offset_in_KJVS_file)
    # Includes mid-book anchors for long books + end-of-text sentinel (__END__)
    _KJVS_CHAPTER_ANCHORS = [
        ("Genesis", 1, 0x9F8E8A),
        ("Genesis", 12, 0xA01607),
        ("Genesis", 25, 0xA0D0ED),
        ("Genesis", 37, 0xA1A0D2),
        ("Exodus", 1, 0xA28B37),
        ("Exodus", 20, 0xA3B2BF),
        ("Deuteronomy", 1, 0xA9B3DD),
        ("Joshua", 1, 0xABECEC),
        ("Judges", 1, 0xAD7215),
        ("Ruth", 1, 0xAEF1B5),
        ("1 Samuel", 1, 0xAF2444),
        ("2 Samuel", 1, 0xB11AD4),
        ("1 Kings", 1, 0xB2B845),
        ("2 Kings", 1, 0xB4A5AB),
        ("1 Chronicles", 1, 0xB67B12),
        ("2 Chronicles", 1, 0xB8295A),
        ("Ezra", 1, 0xBA43D7),
        ("Nehemiah", 1, 0xBAE2D9),
        ("Esther", 1, 0xBBC0A0),
        ("Job", 1, 0xBC363A),
        ("Psalms", 1, 0xBDA334),
        ("Psalms", 23, 0xBE180A),
        ("Psalms", 51, 0xBEC6EB),
        ("Psalms", 119, 0xC078C9),
        ("Proverbs", 1, 0xC11CF2),
        ("Isaiah", 1, 0xC2F969),
        ("Isaiah", 53, 0xC558AE),
        ("Jeremiah", 1, 0xC5EB9A),
        ("Ezekiel", 1, 0xC9976A),
        ("Daniel", 1, 0xCCB548),
        ("Amos", 1, 0xCE38F1),
        ("Obadiah", 1, 0xCE8DEC),
        ("Nahum", 1, 0xCEF520),
        ("Habakkuk", 1, 0xCF0FFF),
        ("Haggai", 1, 0xCF4FDF),
        ("Zechariah", 1, 0xCF662A),
        ("Malachi", 1, 0xCFE655),
        ("Malachi", 4, 0xD0067A),
        ("Matthew", 1, 0xD00A19),
        ("Matthew", 5, 0xD0349D),
        ("Matthew", 28, 0xD1E464),
        ("Mark", 1, 0xD1ECF5),
        ("Luke", 1, 0xD31FE8),
        ("John", 1, 0xD52A55),
        ("John", 3, 0xD548F0),
        ("Acts", 1, 0xD6A696),
        ("Romans", 1, 0xD89EAD),
        ("Romans", 8, 0xD8F18E),
        ("1 Corinthians", 1, 0xD961EA),
        ("2 Corinthians", 1, 0xDA2052),
        ("Galatians", 1, 0xDA9D0B),
        ("Philippians", 1, 0xDB1B7F),
        ("Colossians", 1, 0xDB4899),
        ("1 Thessalonians", 1, 0xDB7277),
        ("1 Timothy", 1, 0xDBADAB),
        ("Titus", 1, 0xDC03AC),
        ("Philemon", 1, 0xDC1808),
        ("Hebrews", 1, 0xDC213A),
        ("James", 1, 0xDCB1D5),
        ("1 Peter", 1, 0xDCE0EC),
        ("2 Peter", 1, 0xDD1546),
        ("1 John", 1, 0xDD3721),
        ("2 John", 1, 0xDD68C3),
        ("3 John", 1, 0xDD6ED1),
        ("Jude", 1, 0xDD7504),
        ("Revelation", 1, 0xDD82B6),
        ("Revelation", 22, 0xDE68C4),
        # End-of-text sentinel: just past the final "Amen." in Rev 22:21
        ("__END__", 0, 0xDE7408),
    ]

    def _build_cumulative_index(self):
        """Build cumulative verse index and book boundaries for lookup."""
        if hasattr(self, "_cum_book_start"):
            return
        self._cum_book_start = {}
        self._book_chapter_counts = {}
        self._total_verses = 0
        for book in self._books:
            vdata = self._verse_counts.get(book)
            if vdata:
                self._cum_book_start[book] = self._total_verses
                self._book_chapter_counts[book] = len(vdata)
                self._total_verses += sum(vdata)
        # Add chapter counts for books known from anchors but not in _books
        if hasattr(self, "_known_books"):
            for book in self._known_books:
                if book not in self._book_chapter_counts:
                    vdata = self._verse_counts.get(book)
                    if vdata:
                        self._book_chapter_counts[book] = len(vdata)
                    else:
                        self._book_chapter_counts[book] = 1  # approximate

    def _clamp_to_book_end(self, offset: int, book: str) -> int:
        if hasattr(self, "_book_last_offset") and book in self._book_last_offset:
            max_offset = self._book_last_offset[book]
            if offset > max_offset:
                return max_offset
        return offset

    def _compute_cum_chapter(self, book: str, chapter: int) -> int | None:
        """Compute cumulative chapter index for (book, chapter) in the canon."""
        self._build_cumulative_index()
        cum = 0
        for b in self._books:
            if b == book:
                return cum + chapter - 1
            cum += self._book_chapter_counts.get(b, 0)
        return None

    # ── LXX1 hardcoded book-start byte offsets ─────────────────────────
    # Empirically located via text search of the LXX1 .atext binary.
    # Each entry: (book_name, first_verse_byte_offset_in_file)
    # For books not yet mapped, use proportional estimation from the
    # nearest known anchors.
    _LXX_BOOK_OFFSETS: dict[str, int] = {
        # Format: book name → byte offset of first verse text
    }

    @staticmethod
    def _lxx_book_names() -> list[str]:
        """Return the 54-book LXX book order used by LXX1."""
        return [
            "Genesis",
            "Exodus",
            "Leviticus",
            "Numbers",
            "Deuteronomy",
            "Joshua",
            "Judges",
            "Ruth",
            "1 Samuel",
            "2 Samuel",
            "1 Kings",
            "2 Kings",
            "1 Chronicles",
            "2 Chronicles",
            "1 Esdras",
            "Ezra",
            "Nehemiah",
            "Esther",
            "Judith",
            "Tobit",
            "1 Maccabees",
            "2 Maccabees",
            "3 Maccabees",
            "4 Maccabees",
            "Psalms",
            "Odes",
            "Proverbs",
            "Ecclesiastes",
            "Song of Solomon",
            "Job",
            "Wisdom",
            "Sirach",
            "Prayer of Solomon",
            "Hosea",
            "Amos",
            "Micah",
            "Joel",
            "Obadiah",
            "Jonah",
            "Nahum",
            "Habakkuk",
            "Zephaniah",
            "Haggai",
            "Zechariah",
            "Malachi",
            "Isaiah",
            "Jeremiah",
            "Baruch",
            "Lamentations",
            "Letter of Jeremiah",
            "Ezekiel",
            "Daniel",
            "Susanna",
            "Bel and the Dragon",
        ]

    # ── Greek module support ─────────────────────────────────────────────
    _GREEK_BOOK_TITLE_MAP = {
        "KATA MAQQAION": "Matthew",
        "KATA MARKON": "Mark",
        "KATA LOUKAN": "Luke",
        "KATA IWANNHN": "John",
        "PRAXEIS APOSTOLWN": "Acts",
        "PROS RWMAIOUS": "Romans",
        "PROS KORINQIOUS A": "1 Corinthians",
        "PROS KORINQIOUS B": "2 Corinthians",
        "PROS GALATAS": "Galatians",
        "PROS EFESIOUS": "Ephesians",
        "PROS FILIPPHSIOUS": "Philippians",
        "PROS KOLOSSAEIS": "Colossians",
        "PROS QESSALONIKEIS A": "1 Thessalonians",
        "PROS QESSALONIKEIS B": "2 Thessalonians",
        "PROS TIMOQEON A": "1 Timothy",
        "PROS TIMOQEON B": "2 Timothy",
        "PROS TITON": "Titus",
        "PROS FILHMONA": "Philemon",
        "PROS EBRAIOUS": "Hebrews",
        "IAKWBOU EPISTOLH": "James",
        "PETROU EPISTOLH A": "1 Peter",
        "PETROU EPISTOLH B": "2 Peter",
        "PETROU EPISTOLH PRWTH": "1 Peter",
        "PETROU EPISTOLH DEUTERA": "2 Peter",
        "IWANNOU EPISTOLH A": "1 John",
        "IWANNOU EPISTOLH B": "2 John",
        "IWANNOU EPISTOLH G": "3 John",
        "IWANNOU EPISTOLH PRWTH": "1 John",
        "IWANNOU EPISTOLH DEUTERA": "2 John",
        "IWANNOU EPISTOLH TRITH": "3 John",
        "IOUDA EPISTOLH": "Jude",
        "APOKALUYIS IWANNOU": "Revelation",
        "PROS KOLASSAEIS": "Colossians",
    }

    def _scan_greek_book_markers(self) -> dict[str, int]:
        """Scan for 20 B9 20 book title markers, returning {canonical_name: text_offset}.
        Also builds _greek_para_markers: [(byte_pos, vt_cum_index), ...] per book."""
        marker = b"\x20\xb9\x20"
        book_map = {}
        # Per-book verse-start byte offsets using immediately-following B9 20 09 markers.
        self._greek_book_text_starts: dict[str, int] = {}
        all_para = []  # global list of (byte_pos, vt_cum_idx)
        # Build global verse-table cumulative
        vt_cum = [0]
        for v in self._greek_vt:
            vt_cum.append(vt_cum[-1] + v)
        # Build vt_idx where each book starts
        book_vt_start = [0]
        for bk in self._books:
            vd = self._verse_counts.get(bk)
            if vd:
                book_vt_start.append(book_vt_start[-1] + sum(vd))
        self._greek_book_vt_start = {}
        for i, bk in enumerate(self._books):
            if i < len(book_vt_start) - 1:
                self._greek_book_vt_start[bk] = book_vt_start[i]

        # Also collect ALL 20 B9 20 09 paragraph markers
        vm = marker + b"\x09"
        pos = 0
        while True:
            p = self.mm.find(vm, pos)
            if p == -1:
                break
            byte_pos = p + 4  # text starts after marker+tab
            # Estimate which verse index this corresponds to
            # Use global VT scaling to map byte position to VT cumulative
            all_para.append(byte_pos)
            pos = p + 1
        # Also scan for 3-byte B9 20 09 markers (GNT-TRS format); prefer
        # whichever scan finds more markers (GNT-TRS lacks leading 0x20).
        all3 = []
        pos = 0
        while True:
            p = self.mm.find(b"\xb9\x20\x09", pos)
            if p == -1:
                break
            all3.append(p + 3)
            pos = p + 1
        if len(all3) > len(all_para):
            self._greek_para_markers = sorted(all3)
        else:
            self._greek_para_markers = sorted(all_para)

        # Now build book title -> text offset map
        pos = 0
        while True:
            p = self.mm.find(marker, pos)
            if p == -1:
                break
            is_std = p + 4 < len(self.mm) and 0x41 <= self.mm[p + 3] <= 0x5A
            is_alt = (
                not is_std
                and p + 5 < len(self.mm)
                and self.mm[p + 3] == 0x20
                and self.mm[p + 4] == 0xC1
                and 0x41 <= self.mm[p + 5] <= 0x5A
            )
            if is_std or is_alt:
                title_off = 5 if is_alt else 3
                end = p + title_off
                while end < len(self.mm) and end < p + 60:
                    if self.mm[end] == 0x0D:
                        break
                    end += 1
                while end > p + title_off and self.mm[end - 1] >= 0x80:
                    end -= 1
                raw = self.mm[p + title_off : end]
                # Filter out high-byte control/ornament characters (e.g. 0xAA in
                # "APOKALUYIS" title of NA28-T Revelation) before decoding.
                raw = bytes(b for b in raw if b < 0x80)
                title = raw.decode("ascii")
                title = " ".join(title.split())  # normalize spaces
                canon_name = self._GREEK_BOOK_TITLE_MAP.get(title)
                if canon_name and canon_name not in book_map:
                    vm_p = self.mm.find(vm, p + len(title) + title_off + 1)
                    if vm_p != -1 and vm_p < p + 600:
                        book_map[canon_name] = vm_p + 4
                    # Build per-book verse-start byte offset using the
                    # immediately-following B9 20 09 verse marker.  The
                    # marker itself (3 bytes) is the exact anchor for
                    # byte-linear interpolation within each book.
                    b9_pos = self.mm.find(b"\xb9\x20\x09", p)
                    if b9_pos != -1:
                        self._greek_book_text_starts[canon_name] = b9_pos + 3
            pos = p + 1
        # Fallback for modules without 20 B9 20 book title markers (GNT-TRS):
        # estimate book boundaries using verse proportions + paragraph markers.
        if not book_map and self._greek_para_markers:
            nt_markers = [p for p in self._greek_para_markers if p >= 0x100000]
            total_verses = sum(sum(self._verse_counts.get(b, ())) for b in self._books)
            cum = 0
            for bk in self._books:
                vd = self._verse_counts.get(bk)
                if not vd:
                    continue
                book_verses = sum(vd)
                # Estimate marker index for this book's first verse
                est_idx = int(cum / max(total_verses, 1) * (len(nt_markers) - 1))
                est_idx = max(0, min(est_idx, len(nt_markers) - 1))
                book_map[bk] = nt_markers[est_idx]
                cum += book_verses
        return book_map

    def _compute_greek_trs_anchors(self) -> dict[str, int]:
        """Build {book: byte_offset} for GNT-TRS using NT verse-count proportion.

        NT text starts at 0x284B43 (Matt 1:1). For each NT book, estimate the
        byte offset as: 0x284B43 + int(cum_verses_before / total_nt_verses * span).
        Where span = (last_paragraph_marker + 200) - 0x284B43.

        These are approximate (paragraph-marker index proportion is inherently
        imprecise) but good enough for within-book byte-linear interpolation.
        """
        pm = self._greek_para_markers
        if not pm:
            return {}

        # Filter to NT region (markers >= 0x100000)
        nt_markers = [p for p in pm if p >= 0x100000]
        if not nt_markers:
            return {}

        TEXT_START = 0x284B43  # Matt 1:1 text start byte offset
        text_end = nt_markers[-1] + 200

        # Build per-book verse counts from _verse_counts (already NT-only)
        nt_verses: dict[str, int] = {}
        for bk in self._books:
            vd = self._verse_counts.get(bk)
            if vd:
                nt_verses[bk] = sum(vd)

        total_nt = sum(nt_verses.values())
        if total_nt == 0:
            return {}

        anchors: dict[str, int] = {}
        cum = 0
        for bk in self._books:
            v = nt_verses.get(bk, 0)
            if v == 0:
                continue
            offset = TEXT_START + int(cum / total_nt * (text_end - TEXT_START))
            anchors[bk] = offset
            cum += v

        return anchors

    def _calibrate_gnt_trs_book_starts(self) -> dict[str, int]:
        """Build {book: text_byte_offset} for GNT-TRS using hardcoded paragraph
        marker indices discovered by scanning all B9 20 09 markers and matching
        first-verse Greek text for every NT book.

        Converts each known marker index to a byte offset via
        self._greek_para_markers[idx] (which stores text-start position +3
        past the B9 20 09 marker).  Falls back to verse-count proportion
        for any book not in the hardcoded index map (shouldn't happen).
        """
        if not self._greek_para_markers:
            return {}

        # Filter to NT region (markers >= 0x100000)
        pm = [p for p in self._greek_para_markers if p >= 0x100000]
        if not pm:
            return {}

        known_idx = self._GNT_TRS_BOOK_MARKER_INDICES
        anchors: dict[str, int] = {}

        # Build fallback proportion estimates for any book not in known_idx
        cum = 0
        total_v = sum(sum(self._verse_counts.get(b, ())) for b in self._books)
        n = len(pm)

        for bk in self._books:
            vd = self._verse_counts.get(bk)
            if not vd:
                continue
            idx = known_idx.get(bk)
            if idx is not None and 0 <= idx < n:
                # Exact: use hardcoded marker index
                anchors[bk] = pm[idx]
            else:
                # Fallback: verse-count proportion (shouldn't be needed)
                est = int(cum / max(total_v, 1) * (n - 1))
                anchors[bk] = pm[max(0, min(est, n - 1))]
            cum += sum(vd)

        self._greek_book_text_starts = dict(anchors)
        return anchors

    # Hardcoded paragraph-marker INDICES for GNT-TRS book starts.
    # Each index is a position in the NT-region (>=0x100000) filtered
    # _greek_para_markers list.  Discovered by scanning all B9 20 09
    # markers and matching first-verse Greek text for every NT book.
    # _greek_para_markers[idx] = text-start byte offset (+3 past marker).
    _GNT_TRS_BOOK_MARKER_INDICES: dict[str, int] = {
        "Matthew": 0,
        "Mark": 138,
        "Luke": 237,
        "John": 403,
        "Acts": 498,
        "Romans": 630,
        "1 Corinthians": 682,
        "2 Corinthians": 731,
        "Galatians": 764,
        "Ephesians": 787,
        "Philippians": 806,
        "Colossians": 821,
        "1 Thessalonians": 839,
        "2 Thessalonians": 856,
        "1 Timothy": 866,
        "2 Timothy": 883,
        "Titus": 894,
        "Philemon": 904,
        "Hebrews": 910,
        "James": 947,
        "1 Peter": 965,
        "2 Peter": 982,
        "1 John": 988,
        "2 John": 1001,
        "3 John": 1004,
        "Jude": 1009,
        "Revelation": 1013,
    }

    def _build_greek_trs_anchors(self) -> dict[str, int]:
        """Build {book: text_byte_offset} for GNT-TRS using paragraph-marker indices.

        Known hardcoded indices: Matthew=0, John=403.
        Other books estimated via verse-count proportion between known anchor points,
        then verified against the text at ±3 markers around the estimate.
        Returns a dict with text-start byte offsets (marker_pos + 3 to skip B9 20 09).
        """
        pm = self._greek_para_markers
        if not pm:
            return {}

        # Filter to NT region (markers >= 0x100000)
        nt_markers = [p for p in pm if p >= 0x100000]
        if not nt_markers:
            return {}

        known = self._GNT_TRS_BOOK_MARKER_INDICES
        book_indices = {}  # {book: marker_index}

        # Collect NT books in order with verse counts
        books_with_verses = []
        total_verses = 0
        for bk in self._books:
            vd = self._verse_counts.get(bk)
            if vd:
                bv = sum(vd)
                books_with_verses.append((bk, bv))
                total_verses += bv

        if total_verses == 0:
            return {}

        # Compute marker indices for all books using known anchors + proportion
        # Build cumulative verse positions for interpolation
        num_markers = len(nt_markers)
        cum = 0

        for bk, bv in books_with_verses:
            if bk in known:
                book_indices[bk] = known[bk]
            else:
                # Use verse-count proportion: estimate marker index
                est = int(cum / max(total_verses, 1) * (num_markers - 1))

                # Verify at ±3 markers: try each candidate and pick the best.
                # Best = the marker whose decoded text matches the expected
                # first-word pattern for this book.
                lo = max(0, est - 3)
                hi = min(num_markers - 1, est + 3)
                best_idx = self._verify_book_start_marker(bk, nt_markers, lo, hi)
                if best_idx is not None:
                    book_indices[bk] = best_idx
                else:
                    book_indices[bk] = est  # fall back to estimate
            cum += bv

        # Convert marker indices to text-start byte offsets (+3 to skip B9 20 09)
        anchors = {}
        for bk, idx in book_indices.items():
            if 0 <= idx < len(nt_markers):
                anchors[bk] = nt_markers[idx] + 3

        return anchors

    _GNT_TRS_EXPECTED_FIRST_WORDS: dict[str, bytes] = {
        # Greek Beta Code first bytes of first verse for each NT book.
        # Used to verify which paragraph marker is the correct book start.
        # Each entry is the first few bytes of the decoded text (in Greek
        # Beta Code encoding as stored in the file).
        "Matthew": b"BIBLOS",
        "Mark": b"ARCH",
        "Luke": b"EPEIDH",
        "John": b"EN",
        "Acts": b"TON MEN",
        "Romans": b"PAULOS",
        "1 Corinthians": b"PAULOS",
        "2 Corinthians": b"PAULOS",
        "Galatians": b"PAULOS",
        "Ephesians": b"PAULOS",
        "Philippians": b"PAULOS",
        "Colossians": b"PAULOS",
        "1 Thessalonians": b"PAULOS",
        "2 Thessalonians": b"PAULOS",
        "1 Timothy": b"PAULOS",
        "2 Timothy": b"PAULOS",
        "Titus": b"PAULOS",
        "Philemon": b"PAULOS",
        "Hebrews": b"POLUMER",
        "James": b"IAKWBOS",
        "1 Peter": b"PETROS",
        "2 Peter": b"SUMEWN",
        "1 John": b"HO HN",
        "2 John": b"HO PRESBU",
        "3 John": b"HO PRESBU",
        "Jude": b"IOUDAS",
        "Revelation": b"APOKALUYIS",
    }

    def _verify_book_start_marker(
        self, book: str, nt_markers: list[int], lo: int, hi: int
    ) -> int | None:
        """Find the marker index in [lo, hi] whose decoded text best matches
        the expected first word of the book. Returns the best index or None."""
        expected = self._GNT_TRS_EXPECTED_FIRST_WORDS.get(book)
        if expected is None:
            return None

        from .encoding import decode as _greek_decode

        best_idx = None
        best_score = 0

        for idx in range(lo, hi + 1):
            pos = nt_markers[idx] + 3  # text start after B9 20 09
            # Read up to 80 bytes and decode
            raw = bytes(self.mm[pos : pos + 80])
            # Truncate at 0x0D (carriage return = verse end) or null
            end = raw.find(b"\x0d")
            if end == -1:
                end = raw.find(b"\x00")
            if end != -1:
                raw = raw[:end]
            # Decode Greek Beta Code to Unicode
            try:
                text = _greek_decode(raw)
            except Exception:
                continue
            # Score: count matching initial characters
            upper = text.upper()
            exp_str = expected.decode("ascii").upper()
            score = 0
            for i in range(min(len(exp_str), len(upper))):
                if upper[i] == exp_str[i]:
                    score += 1
                else:
                    break
            if score > best_score:
                best_score = score
                best_idx = idx

        return best_idx if best_score >= 2 else None

    def _load_greek_verse_table(self, in_init: bool = False) -> list[int]:
        """Load fine verse table at _GREEK_VT_OFFSET as u8 values (padded u16)."""
        vt: list[int] = []
        abbr = self.module_abbr or self.abbrev
        off = (
            self._GREEK_VT_OFFSET.get(abbr, 0)
            if isinstance(self._GREEK_VT_OFFSET, dict)
            else self._GREEK_VT_OFFSET
        )
        for i in range(20000):
            eoff = off + i * 2
            if eoff + 2 > len(self.mm):
                break
            v = (self.mm[eoff] << 8) | self.mm[eoff + 1]
            if v == 0 and len(vt) > 100:
                zc = 0
                for j in range(1, 10):
                    if eoff + j * 2 + 2 <= len(self.mm):
                        nv = (self.mm[eoff + j * 2] << 8) | self.mm[eoff + j * 2 + 1]
                        if nv == 0:
                            zc += 1
                if zc >= 8:
                    break
            vt.append(v)
        return vt

    def _build_greek_vt_offsets(self):
        """Build { (book, ch, v): byte_offset } using global VT-increment scaling."""
        self._greek_verse_offsets: dict = {}
        vt = self._greek_vt
        if not vt:
            return
        # Build cumulative VT increments (section-reset-aware)
        vt_inc = []
        for i, val in enumerate(vt):
            if i == 0 or val < vt[i - 1]:
                vt_inc.append(val)  # First value in section = full value
            else:
                vt_inc.append(val - vt[i - 1])  # Subsequent = increment from prev
        cum_inc = [0]
        for inc in vt_inc:
            cum_inc.append(cum_inc[-1] + inc)

        # Global scaling: single factor across entire NT
        books_in_order = [b for b in self._books if b in self._greek_book_text_starts]
        if not books_in_order:
            return
        text_start = self._greek_book_text_starts[books_in_order[0]]
        pm = self._greek_para_markers
        text_end = pm[-1] + 200 if pm else text_start + 5000000
        total_span = text_end - text_start
        total_inc = cum_inc[-1]
        if total_inc == 0:
            return

        for book in books_in_order:
            bvs = self._greek_book_vt_start.get(book, 0)
            vi = 0
            for ch, vcount in enumerate(self._verse_counts.get(book, []), 1):
                for v in range(1, vcount + 1):
                    vt_idx = bvs + vi
                    if vt_idx >= len(cum_inc):
                        break
                    frac = cum_inc[vt_idx] / total_inc
                    self._greek_verse_offsets[(book, ch, v)] = text_start + int(
                        frac * total_span
                    )
                    vi += 1

    def _build_greek_chapter_anchors(self):
        """Build per-chapter byte-offset anchors for within-book byte-linear
        interpolation.  Chapter 1 of each book uses the exact book anchor.
        Chapters 2+ use verse-count proportion within the book's byte span,
        then snap forward to the first paragraph marker at or after the estimate.

        Populates _greek_chapter_anchors: {(book, ch): byte_offset}.
        Also populates _greek_book_pm: {book: [paragraph_marker_offsets]} for
        use in verse-level snapping.

        GNT-TRS excluded — its hardcoded book-marker indices already provide
        correct book-level interpolation.
        """
        abbr = self.module_abbr or self.abbrev

        self._greek_chapter_anchors: dict[tuple[str, int], int] = {}
        self._greek_book_pm: dict[str, list[int]] = {}
        pm = getattr(self, "_greek_para_markers", None)
        if not pm:
            return

        for book in self._books:
            if book not in self._book_anchors:
                continue
            vdata = self._verse_counts.get(book)
            if not vdata:
                continue

            start = self._book_anchors[book]
            books_in_order = [b for b in self._books if b in self._book_anchors]
            idx = books_in_order.index(book)
            if idx + 1 < len(books_in_order):
                end = self._book_anchors[books_in_order[idx + 1]]
            else:
                end = pm[-1] + 200

            # Collect paragraph markers within this book's byte span
            book_pm = [p for p in pm if start <= p < end]
            self._greek_book_pm[book] = book_pm
            total_v = sum(vdata)
            if total_v == 0:
                continue

            span = end - start
            cum = 0  # verses before current chapter
            for ch, ch_v in enumerate(vdata, 1):
                if ch_v == 0:
                    continue
                if ch == 1:
                    anchor = start  # exact book anchor
                elif book_pm:
                    # Use marker-index proportion for chapter boundary
                    # (avoids byte-density bias from uneven chapter sizes)
                    pm_idx = int(cum / total_v * (len(book_pm) - 1))
                    pm_idx = max(0, min(pm_idx, len(book_pm) - 1))
                    anchor = book_pm[pm_idx]
                else:
                    anchor = start + int(cum / total_v * span)
                self._greek_chapter_anchors[(book, ch)] = anchor
                cum += ch_v

        # Override chapter anchors with hardcoded verse-1 anchors for accuracy.
        # Proportion-based estimation can drift for uneven chapters (e.g., Matt 5
        # where the B9 20 09 marker for Matt 4:18 is near the boundary), so we
        # use exact byte offsets from _GREEK_VERSE_ANCHORS when available.
        verse_anchors = self._GREEK_VERSE_ANCHORS.get(abbr, {})
        for (va_book, va_ch, va_v), va_off in verse_anchors.items():
            if va_v == 1 and va_book in self._books:
                self._greek_chapter_anchors[(va_book, va_ch)] = va_off

    def _snap_forward_in_list(
        self, est: int, markers: list[int], window: int = 5000
    ) -> int:
        """Snap est forward to the first marker in `markers` >= est.
        Fallback to est if gap exceeds window bytes."""
        if not markers:
            return est
        lo, hi = 0, len(markers) - 1
        while lo <= hi:
            mid = (lo + hi) // 2
            if markers[mid] < est:
                lo = mid + 1
            else:
                hi = mid - 1
        if lo < len(markers) and markers[lo] - est <= window:
            return markers[lo]
        return est

    def _snap_backward_in_list(
        self, est: int, markers: list[int], window: int = 2000
    ) -> int:
        """Snap est backward to the last marker in `markers` <= est.
        Fallback to est if gap exceeds window bytes."""
        if not markers:
            return est
        lo, hi = 0, len(markers) - 1
        while lo <= hi:
            mid = (lo + hi) // 2
            if markers[mid] <= est:
                lo = mid + 1
            else:
                hi = mid - 1
        if hi >= 0 and est - markers[hi] <= window:
            return markers[hi]
        return est

    def _snap_closest_in_list(
        self, est: int, markers: list[int], window: int = 5000
    ) -> int:
        """Snap est to the closest marker in `markers` (bidirectional).
        Fallback to est if no marker within window bytes."""
        if not markers:
            return est
        lo, hi = 0, len(markers) - 1
        while lo <= hi:
            mid = (lo + hi) // 2
            if markers[mid] < est:
                lo = mid + 1
            else:
                hi = mid - 1
        candidates = []
        if lo < len(markers) and markers[lo] - est <= window:
            candidates.append(markers[lo])
        if hi >= 0 and est - markers[hi] <= window:
            candidates.append(markers[hi])
        if not candidates:
            return est
        return min(candidates, key=lambda x: abs(x - est))

    def _snap_to_nearest_greek_marker(self, est: int, book: str, chapter: int) -> int:
        """Snap estimated offset to the LAST paragraph marker BEFORE est.

        A verse always starts at or after a paragraph marker — never before it.
        So we must snap backward-only: the largest marker <= est.
        """
        pm = self._greek_para_markers
        # Binary search: find largest marker <= est
        lo, hi = 0, len(pm) - 1
        while lo <= hi:
            mid = (lo + hi) // 2
            if pm[mid] <= est:
                lo = mid + 1
            else:
                hi = mid - 1
        # hi is index of last marker <= est
        if hi < 0:
            return est
        best = pm[hi]
        if est - best > 2000:
            return est
        return best

    def _snap_forward_to_marker(self, est: int, window: int = 3000) -> int:
        """Snap est forward to the FIRST paragraph marker AT or AFTER est.

        Used for chapter-anchor calibration: the chapter's first paragraph
        marker is at or after the estimated chapter-start position.
        Falls back to est if no marker found within window bytes forward.
        """
        pm = self._greek_para_markers
        if not pm:
            return est
        # Binary search for first marker >= est
        lo, hi = 0, len(pm) - 1
        while lo <= hi:
            mid = (lo + hi) // 2
            if pm[mid] < est:
                lo = mid + 1
            else:
                hi = mid - 1
        if lo < len(pm) and pm[lo] - est <= window:
            return pm[lo]
        return est

    def _snap_to_closest_marker(self, est: int, window: int = 2000) -> int:
        """Snap est to the closest paragraph marker (bidirectional).

        Finds the paragraph marker with the smallest absolute distance
        to est.  Falls back to est if no marker is within window bytes.
        """
        pm = self._greek_para_markers
        if not pm:
            return est
        # Binary search for the first marker >= est
        lo, hi = 0, len(pm) - 1
        while lo <= hi:
            mid = (lo + hi) // 2
            if pm[mid] < est:
                lo = mid + 1
            else:
                hi = mid - 1
        # lo is the first index with pm[lo] >= est (or len(pm) if none)
        candidates = []
        if lo < len(pm):
            candidates.append(pm[lo])
        if lo > 0:
            candidates.append(pm[lo - 1])
        if not candidates:
            return est
        best = min(candidates, key=lambda x: abs(x - est))
        if abs(best - est) <= window:
            return best
        return est

    def _find_verse_offset(self, book: str, chapter: int, verse: int) -> int | None:
        """Get the text byte offset for a verse.

        Delegates to VerseOffsetResolver which chains offset-finding
        strategies by priority and returns the first non-None result.
        """
        return self._offset_resolver.resolve(self, book, chapter, verse)

    def _find_text_after_marker(self, pos: int) -> int | None:
        """Given a position at a marker byte (0xA6 or 0x2A), find the
        start of the actual verse text after skipping format bytes, spaces,
        digits (verse numbers), and any 0xD1-0xDA formatting bytes.

        Returns the byte position of the first uppercase letter, or None."""
        limit = min(pos + 40, len(self.mm))
        while pos < limit:
            b = self.mm[pos]
            # Skip non-ASCII format/control bytes
            if b >= 0x80:
                pos += 1
                continue
            # Skip whitespace
            if b in (0x20, 0x09, 0x0A):
                pos += 1
                continue
            # Skip 0xD1-0xDA formatting bytes
            if 0xD1 <= b <= 0xDA:
                pos += 1
                continue
            # Skip digits (verse numbers)
            if 0x30 <= b <= 0x39:
                pos += 1
                continue
            # Uppercase letter = text start
            if 0x41 <= b <= 0x5A:
                return pos
            # Hit the next marker before text — no text follows this one
            if b in (0x0D, 0x2A, 0xA6):
                return None
            # Unexpected byte — skip cautiously
            pos += 1
        return None

    def _refine_verse_position(
        self,
        approx_offset: int,
        target_verse: Optional[int] = None,
        chapter_anchor: Optional[int] = None,
    ) -> int | None:
        """Refine verse position using 0xA6 markers and sentence-boundary patterns.

        In ESVS, 0xA6 marks paragraph/section breaks that often coincide with
        verse boundaries.  When that isn't available, period+uppercase patterns
        mark sentence boundaries (which overlap heavily with verse boundaries).

        Searches bidirectionally (±400 bytes) from approx_offset for the
        nearest valid text-start boundary.

        Returns the refined text-start position, or None if no boundary found.
        """
        search_range = 400
        a6_candidates = []  # (text_start, distance) — 0xA6/0x2A explicit markers
        weak_candidates = []  # (text_start, distance) — period+UC, D1-DA formatting

        for direction in (-1, 1):
            pos = approx_offset
            for _ in range(search_range):
                pos += direction
                if pos < 1 or pos + 2 >= len(self.mm):
                    break

                b = self.mm[pos]

                # ── 0xA6 marker: strongest signal (paragraph/section break) ──
                if b == 0xA6:
                    text_start = self._find_text_after_marker(pos + 1)
                    if text_start is not None:
                        a6_candidates.append(
                            (text_start, abs(text_start - approx_offset))
                        )
                    continue

                # ── Period + uppercase: sentence boundary (often a verse start) ──
                if b == 0x2E:  # period
                    look = pos + 1
                    # Skip any space/format/digit bytes between period and uppercase
                    while look < min(pos + 8, len(self.mm)):
                        nb = self.mm[look]
                        if nb in (0x20, 0x09, 0x0A) or nb >= 0x80:
                            look += 1
                            continue
                        if 0x30 <= nb <= 0x39:  # footnote digit
                            look += 1
                            continue
                        if 0x41 <= nb <= 0x5A:  # uppercase → sentence start
                            # The new sentence starts at 'look'
                            weak_candidates.append((look, abs(look - approx_offset)))
                        break

                # ── 0x2A marker: present in some modules (KJVS, etc.) ──
                elif b == 0x2A:
                    text_start = self._find_text_after_marker(pos + 1)
                    if text_start is not None:
                        a6_candidates.append(
                            (text_start, abs(text_start - approx_offset))
                        )

                # ── 0xD1-0xDA: formatting bytes near sentence breaks ──
                elif 0xD1 <= b <= 0xDA:
                    prev_b = self.mm[pos - 1] if pos > 0 else 0
                    if prev_b in (0x2E, 0x20, 0x3B, 0x3A) or (0xD1 <= prev_b <= 0xDA):
                        text_start = pos + 1
                        while (
                            text_start < len(self.mm)
                            and 0xD1 <= self.mm[text_start] <= 0xDA
                        ):
                            text_start += 1
                        while text_start < len(self.mm) and self.mm[text_start] in (
                            0x20,
                            0x09,
                            0x0A,
                        ):
                            text_start += 1
                        if (
                            text_start < len(self.mm)
                            and 0x41 <= self.mm[text_start] <= 0x5A
                        ):
                            weak_candidates.append(
                                (text_start, abs(text_start - approx_offset))
                            )

        # ── Forward-only pass: find the NEXT 0xA6 marker from approx_offset ──
        # When the estimate lands BETWEEN two verse boundaries (e.g., Rev 22:20
        # and 22:21), the bidirectional search may lock onto the previous verse's
        # 0xA6 (found backward) because it's closer.  This forward-only scan
        # ensures the NEXT verse boundary is considered as a candidate.
        # Range: 600 bytes forward (wider than the 400-byte bidirectional range,
        # to catch distant verse 21 boundaries in long-verse chapters).
        for sc_fwd in range(approx_offset, min(approx_offset + 600, len(self.mm))):
            if self.mm[sc_fwd] == 0xA6:
                text_start = self._find_text_after_marker(sc_fwd + 1)
                if text_start is not None:
                    a6_candidates.append((text_start, abs(text_start - approx_offset)))
                break

        # Weight 0xA6/0x2A markers at 0.5x distance — prefer them over
        # sentence-boundary candidates but don't ignore distance entirely.
        # A 0xA6 at distance 60 scores 30 (weighted), beating a period+UC at
        # distance 31 by 1. A distant 0xA6 at 200 scores 100, losing to a
        # close period+UC at 30. This prevents picking a wrong-chapter 0xA6
        # when the correct verse boundary is a nearby sentence break.
        all_candidates = [(pos, dist * 0.5) for pos, dist in a6_candidates]
        all_candidates += [(pos, float(dist)) for pos, dist in weak_candidates]
        if all_candidates:
            all_candidates.sort(key=lambda x: x[1])
            return all_candidates[0][0]

        return None

    def _estimate_verse_via_t1(
        self, book: str, chapter: int, verse: int, chapter_anchor: int
    ) -> int | None:
        """Estimate verse byte offset using T1 cumulative word counts.

        chapter_anchor must already be refined (via _refine_chapter_anchor)
        to the actual chapter start position.
        """
        self._load_word_map()
        self._build_cumulative_index()

        if not self._t1:
            return None

        vdata = self._verse_counts.get(book)
        if not vdata or chapter > len(vdata):
            return None

        ch_vcount = vdata[chapter - 1]
        if verse < 1 or verse > ch_vcount:
            return None

        # T1 indices
        ch_start_idx = self._find_t1_verse_index(book, chapter, 1)
        target_idx = self._find_t1_verse_index(book, chapter, verse)
        if ch_start_idx is None or target_idx is None:
            return None
        if ch_start_idx >= len(self._t1) or target_idx >= len(self._t1):
            return None

        # Word positions (cumulative through each verse)
        ch_word_start = self._t1[ch_start_idx - 1] if ch_start_idx > 0 else 0
        verse_word_start = self._t1[target_idx - 1] if target_idx > 0 else 0
        verse_word_offset = verse_word_start - ch_word_start
        if verse_word_offset < 0:
            verse_word_offset = 0

        # Use a per-book bytes-per-word ratio. OT text averages ~4.07 bytes/word
        # but NT has more formatting overhead (footnotes, cross-references) that
        # pushes the effective ratio to ~4.55. For long NT chapters (Romans 8, etc.)
        # this prevents the ~10% cumulative drift that throws off verse positioning.
        #
        # KJVS text is denser (KJV uses fewer words, older style) — use 4.0.
        #
        # OT books: Genesis through Malachi (indices 0-38 in _BOOKS)
        # NT books: Matthew through Revelation (indices 39+)
        nt_books = {
            "Matthew",
            "Mark",
            "Luke",
            "John",
            "Acts",
            "Romans",
            "1 Corinthians",
            "2 Corinthians",
            "Galatians",
            "Ephesians",
            "Philippians",
            "Colossians",
            "1 Thessalonians",
            "2 Thessalonians",
            "1 Timothy",
            "2 Timothy",
            "Titus",
            "Philemon",
            "Hebrews",
            "James",
            "1 Peter",
            "2 Peter",
            "1 John",
            "2 John",
            "3 John",
            "Jude",
            "Revelation",
        }
        abbr = self.module_abbr or self.abbrev
        if abbr == "KJVS":
            FIXED_BPW = 4.0
        else:
            FIXED_BPW = 4.55 if book in nt_books else 4.07

        return chapter_anchor + int(verse_word_offset * FIXED_BPW)

    def _refine_chapter_anchor(self, approx_anchor: int) -> int:
        """Refine approximate chapter anchor by searching for nearest 0xA6 marker.

        Searches BOTH directions from approx_anchor for the nearest 0xA6+space
        marker that starts a chapter/section. Returns the text position after
        the marker, or approx_anchor if no marker found within range.
        """
        search_range = 3000
        best_pos = None
        best_dist = float("inf")

        def _check_a6(pos: int):
            """Check if pos has a valid 0xA6 marker; return text_pos or None."""
            nonlocal best_pos, best_dist
            if self.mm[pos] != 0xA6:
                return
            b1 = self.mm[pos + 1]
            b2 = self.mm[pos + 2]
            text_pos = None
            # Standard: 0xA6 + space + [A-Z or 0-9]
            if b1 == 0x20 and ((0x41 <= b2 <= 0x5A) or (0x30 <= b2 <= 0x39)):
                text_pos = pos + 2
            # Format variant: 0xA6 + space + footnote lowercase (0x61-0x7A) + [A-Z]
            # Example: Exodus 20:1 has 0xA6 0x20 0x7A 'A' (0x7A = footnote 'z')
            elif b1 == 0x20 and 0x61 <= b2 <= 0x7A:
                if pos + 3 < len(self.mm):
                    b3 = self.mm[pos + 3]
                    if (0x41 <= b3 <= 0x5A) or (0x30 <= b3 <= 0x39):
                        text_pos = pos + 3
            # Format variant: 0xA6 + footnote (0x61-0x7A) + verse marker (0xD1-0xD2) + [A-Z]
            # Example: Exodus 20:2 has 0xA6 0x62 0xD2 'I am' (no space, footnote+vm)
            elif 0x61 <= b1 <= 0x7A and b2 in (0xD1, 0xD2):
                if pos + 3 < len(self.mm):
                    b3 = self.mm[pos + 3]
                    if (0x41 <= b3 <= 0x5A) or (0x30 <= b3 <= 0x39):
                        text_pos = pos + 3
            # Format variant: 0xA6 + space + [0x09/0x0A] + [optional lower] + [optional D1-DA] + [A-Z]
            elif b1 == 0x20 and b2 in (0x09, 0x0A):
                tp = pos + 3
                while tp < len(self.mm) and 0x61 <= self.mm[tp] <= 0x7A:
                    tp += 1
                # Skip verse-marker/formatting bytes (D1-DA) after footnote letter
                # (e.g., A6 20 09 68 D2 53 = footnote 'h' + verse-marker D2 + 'Sing')
                while tp < len(self.mm) and 0xD1 <= self.mm[tp] <= 0xDA:
                    tp += 1
                if tp < len(self.mm):
                    bt = self.mm[tp]
                    if (0x41 <= bt <= 0x5A) or (0x30 <= bt <= 0x39):
                        text_pos = tp
            if text_pos is not None:
                dist = abs(text_pos - approx_anchor)
                if dist < best_dist:
                    best_dist = dist
                    best_pos = text_pos

        # Check the anchor position itself first (was previously skipped!)
        if approx_anchor + 3 < len(self.mm):
            _check_a6(approx_anchor)

        # Search forward and backward
        for direction in (-1, 1):
            pos = approx_anchor
            while abs(pos - approx_anchor) < search_range:
                pos += direction
                if pos < 0 or pos + 4 >= len(self.mm):
                    break
                _check_a6(pos)

        return best_pos if best_pos is not None else approx_anchor

    def _estimate_local_bytes_per_word(
        self, book: str, chapter: int, ch_start_idx: int, ch_word_start: int
    ) -> float:
        """Estimate the local bytes-per-word ratio near this chapter using T1 anchors.

        Finds the tightest pair of hardcoded anchors that bracket the chapter's
        T1 word position, producing the most accurate local bytes-per-word ratio.

        For example, John 3 uses John 1→Acts 1 (tight bracket), not some distant
        OT anchor pair.
        """
        anchor_pairs = []
        for a_book, a_ch, a_off in self._CHAPTER_ANCHORS:
            a_idx = self._find_t1_verse_index(a_book, a_ch, 1)
            if a_idx is not None and a_idx < len(self._t1):
                a_word = self._t1[a_idx]
                anchor_pairs.append((a_word, a_off))

        if len(anchor_pairs) < 2:
            return 3.8  # default bytes/word for English Bible text

        anchor_pairs.sort()

        # Find the tightest bracket: anchors closest in word-space that
        # surround the chapter's T1 position
        ch_word = (
            self._t1[ch_start_idx] if ch_start_idx < len(self._t1) else ch_word_start
        )

        best_before = best_after = None
        best_gap = float("inf")

        for i, (aw, ab) in enumerate(anchor_pairs):
            if aw <= ch_word:
                # Look for the closest after-anchor
                for j in range(i + 1, len(anchor_pairs)):
                    bw, bb = anchor_pairs[j]
                    if bw > ch_word:
                        gap = bw - aw
                        if gap < best_gap:
                            best_gap = gap
                            best_before = (aw, ab)
                            best_after = (bw, bb)
                        break

        if best_before and best_after and best_before[0] < best_after[0]:
            w_diff = best_after[0] - best_before[0]
            b_diff = best_after[1] - best_before[1]
            return b_diff / max(w_diff, 1)

        # Fallback: first before and first after (may be non-tight)
        before = after = None
        for aw, ab in anchor_pairs:
            if aw <= self._t1[ch_start_idx]:
                before = (aw, ab)
            if aw >= self._t1[ch_start_idx] and after is None:
                after = (aw, ab)

        if before and after and before[0] < after[0]:
            w_diff = after[0] - before[0]
            b_diff = after[1] - before[1]
            return b_diff / max(w_diff, 1)

        # Global fallback
        first = anchor_pairs[0]
        last = anchor_pairs[-1]
        w_total = last[0] - first[0]
        b_total = last[1] - first[1]
        return b_total / max(w_total, 1)

    def get_verse(self, reference: str) -> str | None:
        ref = parse_reference(reference)
        if ref is None:
            return None

        book, chapter, verse = ref
        normalized = _normalize_book_name(book, set(self._books))
        # Fallback: if anchor system knows this book (e.g., NETS deutero books
        # that aren't in the 66-book Protestant header), use original name
        if (
            normalized is None
            and hasattr(self, "_known_books")
            and book in self._known_books
        ):
            normalized = book
        if normalized is None:
            return None

        offset = self._find_verse_offset(book, chapter, verse)
        if offset is None:
            return None

        return self._extract_verse_text(
            offset, decode_greek=self._is_greek, decode_hebrew=self._is_hebrew
        )

    def iter_canon_verses(self) -> Iterator[tuple[str, int, int, str]]:
        """Walk the full canon, yielding (book, chapter, verse, decoded_text)
        for every verse using the encoding.decode() path for Greek text."""
        for book in self._books:
            vdata = self._verse_counts.get(book)
            if not vdata:
                continue
            for chapter in range(1, len(vdata) + 1):
                for verse in range(1, vdata[chapter - 1] + 1):
                    offset = self._find_verse_offset(book, chapter, verse)
                    if offset is None:
                        continue
                    text = self._extract_verse_text(
                        offset, decode_greek=True, decode_hebrew=self._is_hebrew
                    )
                    yield (book, chapter, verse, text)

    @staticmethod
    def _matches_footnote_word(raw: bytes, i: int) -> bool:
        """Check if the lowercase letter at raw[i] is a footnote marker
        preceding a common English word. Uses a dictionary-based approach:
        if the word WITHOUT the first letter is a known word AND the word
        WITH the first letter is NOT a known word, the first letter is a
        footnote marker to be stripped.

        Returns True if the lowercase letter at raw[i] should be stripped."""
        # Find the end of the word starting at i (potential marker letter)
        j = i
        while j < len(raw) and (0x41 <= raw[j] <= 0x5A or 0x61 <= raw[j] <= 0x7A):
            j += 1

        if j - i < 3:  # Need at least marker + 2-char word (e.g. "m" + "to")
            return False

        # Full word including potential marker letter
        try:
            full = raw[i:j].decode("ascii").lower()
            remainder = raw[i + 1 : j].decode("ascii").lower()
        except UnicodeDecodeError:
            return False

        word_set = TextModule._load_word_set()

        # If the full word (including potential marker) is a known word,
        # the first letter is legitimate (e.g. "the", "that", "begin")
        if full in word_set:
            return False

        # If stripping the first letter yields a known word, it's a marker
        if remainder in word_set:
            return True

        # Check common suffixes on the remainder (e.g. "restores" → "restore",
        # "pastures" → "pasture"). Only check if the marker+remainder itself
        # is not a known word (to avoid false stripping of legitimate words).
        for suffix in ("s", "es", "ed", "ing"):
            if remainder.endswith(suffix) and len(remainder) > len(suffix) + 1:
                stem = remainder[: -len(suffix)]
                if stem in word_set:
                    # Guard: if the full word's first 4 letters form a known
                    # English prefix, the word is likely real (e.g. "flashes"
                    # stems to "lash" but "flas" is a real prefix → don't strip).
                    prefixes = TextModule._WORD_PREFIX_4
                    if prefixes and len(full) >= 4 and full[:4] in prefixes:
                        return False
                    return True

        return False

    @staticmethod
    def _split_concatenated_words(text: str) -> str:
        """Split sequences where two known English words have been
        concatenated without a space (e.g. "offand" → "off and").
        Uses the word-set to detect valid split points.

        Returns the text with spaces inserted at word boundaries where
        needed."""
        word_set = TextModule._load_word_set()
        if not word_set:
            return text

        words = text.split()
        result_words = []
        for w in words:
            if len(w) < 5 or w.isupper():
                result_words.append(w)
                continue
            # Try splitting at each position. Require each half to be
            # at least 3 chars to avoid false splits from short words
            # (e.g. "of" as a 2-char left half, or "es" as a suffix).
            best_split = None
            for split_pos in range(3, len(w) - 2):
                left = w[:split_pos]
                right = w[split_pos:]
                # Both halves must be known words, and the full string
                # should NOT be a known word (avoid splitting real words)
                if (
                    left in word_set
                    and right in word_set
                    and f"{left}{right}" not in word_set
                ):
                    # Guard: if the word's first 4 letters form a known
                    # English prefix, don't split (e.g. "created" → "crea" +
                    # "ted" — "crea" is a real prefix → don't split).
                    prefixes = TextModule._WORD_PREFIX_4
                    if prefixes and len(w) >= 4 and w[:4] in prefixes:
                        continue
                    best_split = (left, right)
                    break  # First valid split found
            if best_split:
                result_words.append(best_split[0])
                result_words.append(best_split[1])
            else:
                result_words.append(w)
        return " ".join(result_words)

    def _extract_verse_text(
        self,
        offset: int,
        max_chars: int = 200,
        decode_greek: bool = False,
        decode_hebrew: bool = False,
    ) -> str:
        # Find end boundary: for Greek text use 20 B9 20 markers;
        # otherwise \r, 0xA6, 0x2A, period+uppercase.

        # ── Detect if the text at this offset is valid UTF-8 ──────────
        # 0xA6 (0b10100110) is a valid UTF-8 continuation byte that
        # appears inside Chinese/CJK multi-byte sequences.  In Accordance's
        # Latin-1 binary format, 0xA6 is a section/paragraph marker.
        # We can't distinguish these by byte value alone — but if the
        # first ~100 bytes at the offset decode as mostly clean UTF-8
        # (allowing up to 2 replacement chars for possible trailing
        # incomplete sequences), then 0xA6 bytes in the search window are
        # part of Unicode text, not markers.
        _is_utf8_text = False
        try:
            sample = self.mm[offset : offset + 100]
            decoded = sample.decode("utf-8", errors="replace")
            rc = decoded.count("\ufffd")
            # Allow up to 2 U+FFFD from trailing incomplete sequences
            if rc <= 2 and any(ord(c) > 127 for c in decoded.replace("\ufffd", "")):
                _is_utf8_text = True
        except (IndexError, UnicodeDecodeError):
            pass

        end = offset
        search_end = min(offset + max_chars + 60, len(self.mm))
        for i in range(offset + 20, search_end):
            b = self.mm[i]
            if decode_greek and i + 3 <= search_end:
                if b == 0x20 and self.mm[i + 1] == 0xB9 and self.mm[i + 2] == 0x20:
                    end = i
                    break
                continue
            # 0x0D (\r) is a universal verse/paragraph boundary
            if b == 0x0D:
                end = i
                break
            # 0xA6 and 0x2A are Hebrew diacritic bytes — do NOT stop on them
            # when decoding Hebrew (they're part of the encoded text).
            # Also skip them when the text appears to be valid UTF-8, since
            # 0xA6 is a legal UTF-8 continuation byte (0b10xxxxxx) that
            # appears inside Chinese/CJK multi-byte sequences.
            if not decode_hebrew and (b == 0xA6 or b == 0x2A):
                if not _is_utf8_text:
                    end = i
                    break
            if b == 0x2E and i + 1 < search_end and 0x41 <= self.mm[i + 1] <= 0x5A:
                end = i + 1
                break
        else:
            end = min(offset + max_chars, len(self.mm))

        raw = self.mm[offset:end]
        if decode_greek:
            from .encoding import decode_always as _greek_decode

            text = _greek_decode(raw)
            null_pos = text.find("\x00")
            if null_pos != -1:
                text = text[:null_pos]
            return text.strip()
        if decode_hebrew:
            from .encoding import decode_hebrew as _hebrew_decode

            etcbc = (self.module_abbr or self.abbrev) == "MT-ETCBC-A"
            result = _hebrew_decode(raw, etcbc=etcbc).strip()
            # HMT-DEMO uses ; as morphology separator in verse text
            if (self.module_abbr or self.abbrev) == "HMT-DEMO":
                result = result.replace(";", "")
            return result
        # ── UTF-8 / Unicode text detection ──
        # Two-pass approach:
        #   1. Try raw bytes as UTF-8 (handles Chinese, Romanian, etc.)
        #   2. If that fails, apply MacRoman→UTF-8 conversion and try again
        #      (handles English/Spanish modules with MacRoman smart quotes,
        #      dashes, and accented characters)
        #   3. Fall through to byte-by-byte ASCII filter for everything else.
        #
        # IMPORTANT: MacRoman pre-conversion must NOT run before the first
        # UTF-8 attempt because MacRoman bytes like 0x80-0x9F overlap with
        # valid UTF-8 continuation bytes in Chinese/CJK multi-byte sequences.
        for pass_label, pass_bytes in [
            ("utf8-raw", raw),
            ("utf8-macroman", _prep_macroman_bytes(raw)),
        ]:
            try:
                text = pass_bytes.decode("utf-8", errors="replace")
                # Strip trailing U+FFFD that may come from an incomplete
                # UTF-8 sequence at the end of the slice (max_chars cuts
                # at a byte boundary, not a character boundary).
                text = text.rstrip("\ufffd")
                if text.count("\ufffd") == 0:
                    has_unicode = any(ord(c) > 127 for c in text)
                    if has_unicode:
                        # Strip Accordance tag/formatting markers:
                        # - Asterisks (*, **) are formatting/tag markers
                        # - Tabs are layout artifacts
                        result = text.replace("**", "").replace("*", "")
                        result = result.replace("\t", " ").strip()
                        return result
            except Exception:
                pass
        result_list: list[str] = []
        i = 0
        prev_c = 0  # Track previous char for digit/footnote context checks
        while i < len(raw):
            b = raw[i]
            if b >= 0x80:
                i += 1
                continue
            if 0x20 <= b < 0x7F:
                # ── Strip footnote markers (isolated lowercase letters) ──
                if 0x61 <= b <= 0x7A and i > 0 and i + 1 < len(raw):
                    prev_c = raw[i - 1]
                    next_c = raw[i + 1]
                    prev_alpha = (0x41 <= prev_c <= 0x5A) or (0x61 <= prev_c <= 0x7A)
                    next_alpha = (0x41 <= next_c <= 0x5A) or (0x61 <= next_c <= 0x7A)
                    # Isolated between non-alpha chars (e.g. " x ") → strip
                    if not prev_alpha and not next_alpha:
                        i += 1
                        continue
                    # Footnote marker before a word (non-alpha + lowercase + alpha)
                    if not prev_alpha and next_alpha:
                        prev_non_alpha = (i == 0) or (
                            not prev_alpha and not (0x30 <= prev_c <= 0x39)
                        )
                        if prev_non_alpha:
                            stripped = False
                            # Any lowercase before an uppercase letter (e.g. "hGod" → "God")
                            if 0x41 <= next_c <= 0x5A:
                                stripped = True
                            # a-d before same letter (e.g. "aa" → "a")
                            elif 0x61 <= b <= 0x7A and next_c == b:
                                stripped = True
                            # Any lowercase followed by very common Bible words
                            # (footnote markers in Accordance format)
                            elif not stripped:
                                stripped = self._matches_footnote_word(raw, i)
                            if stripped:
                                i += 1
                                continue
                # ── Strip verse numbers / footnote refs (isolated digits) ──
                if 0x30 <= b <= 0x39 and i > 0:
                    next_is_digit = i + 1 < len(raw) and 0x30 <= raw[i + 1] <= 0x39
                    prev_is_digit = 0x30 <= prev_c <= 0x39
                    if not next_is_digit and not prev_is_digit:
                        # Precedes alpha → verse number ("1The" → "The")
                        next_is_alpha = i + 1 < len(raw) and (
                            (0x41 <= raw[i + 1] <= 0x5A) or (0x61 <= raw[i + 1] <= 0x7A)
                        )
                        if next_is_alpha:
                            i += 1
                            continue
                        # Follows alpha → footnote ref ("said1" → "said")
                        prev_is_alpha = (0x41 <= prev_c <= 0x5A) or (
                            0x61 <= prev_c <= 0x7A
                        )
                        if prev_is_alpha:
                            i += 1
                            continue
                prev_c = b
                result_list.append(chr(b))
            i += 1

        text = "".join(result_list).strip()
        # ── Post-process: split concatenated words (e.g. "offand" → "off and") ──
        text = TextModule._split_concatenated_words(text)
        return text

    def _find_entry_block(
        self, search_start: int, max_scan: int = 0x800
    ) -> list[int] | None:
        """Scan forward from search_start for a 00 XX YY ZZ entry block with
        a duplicate uint24 BE terminator. Returns list of uint24 BE values
        (excluding the terminator duplicate), or None if no block found.
        """
        limit = min(search_start + max_scan, len(self.mm))
        off = search_start
        while off < limit - 8:
            if self.mm[off] != 0x00:
                off += 1
                continue

            block_start: int | None = off
            type_byte = self.mm[off + 1]
            values: list[int] = []
            for i in range(200):
                eoff = block_start + i * 4
                if eoff + 4 > limit:
                    break
                if self.mm[eoff] != 0x00 or self.mm[eoff + 1] != type_byte:
                    if i >= 2:
                        break
                    else:
                        off = eoff + 1
                        block_start = None
                        break
                u24 = self._read_u24_be_after_tag(eoff)
                if values and u24 < values[-1]:
                    assert block_start is not None
                    off = block_start + 1
                    block_start = None
                    break
                values.append(u24)
                if i >= 1 and u24 == values[-2]:
                    return values[:-1]
            if block_start is None:
                continue
            off += 1

        return None

    def _find_page_base(self, verse_offset: int) -> int | None:
        """Find the page base offset (entry block start) that contains verse_offset.

        Uses the page table if available; otherwise scans for entry blocks.
        """
        if self._page_table_offset is not None:
            return self._find_page_base_via_table(verse_offset)
        return self._find_page_base_via_scan(verse_offset)

    KJVS_PAGE_SIZE = 0x100  # 256 bytes per page unit for KJVS u16 index table

    def _find_page_base_via_table(self, verse_offset: int) -> int | None:
        """Use the page table to find the page containing verse_offset."""
        pt = self._page_table_offset
        assert pt is not None
        is_u16 = self._page_table_format == "u16"

        best = None
        for idx in range(50000):
            eoff = pt + idx * 4
            if eoff + 4 > len(self.mm):
                break
            if is_u16:
                u16 = struct.unpack_from(">H", self.mm, eoff)[0]
                page_start = u16 * self.KJVS_PAGE_SIZE
            else:
                page_start = self._read_u24_be_after_tag(eoff)
            if page_start == 0:
                break
            if page_start > verse_offset:
                break
            # Skip entry [0] (base value) and entries before text area
            if idx > 0 and page_start < 0x100000:
                continue
            best = page_start
        return best

    def _find_page_base_via_scan(self, verse_offset: int) -> int | None:
        """Find page base by scanning for entry blocks (fallback, less reliable)."""
        search_start = max(0, verse_offset - 0x4000)
        limit = min(verse_offset + 0x4000, len(self.mm))
        blocks = []

        off = search_start
        while off < limit - 8:
            if self.mm[off] != 0x00:
                off += 1
                continue

            block_start: int | None = off
            type_byte = self.mm[off + 1]
            values: list[int] = []
            found_terminator = False
            for i in range(200):
                eoff = block_start + i * 4
                if eoff + 4 > limit:
                    break
                if self.mm[eoff] != 0x00 or self.mm[eoff + 1] != type_byte:
                    if i >= 2:
                        found_terminator = False
                        break
                    else:
                        off = eoff + 1
                        block_start = None
                        break
                u24 = self._read_u24_be_after_tag(eoff)
                if values and u24 < values[-1]:
                    assert block_start is not None
                    off = block_start + 1
                    block_start = None
                    found_terminator = False
                    break
                values.append(u24)
                if i >= 1 and u24 == values[-2]:
                    assert block_start is not None
                    block_end = eoff + 4
                    blocks.append((block_start, block_end))
                    off = block_end
                    block_start = None
                    found_terminator = True
                    break
            if block_start is None:
                if found_terminator:
                    continue
                else:
                    off += 1
                    continue
            off += 1

        if not blocks:
            return None

        for i, (bs, be) in enumerate(blocks):
            if be > verse_offset:
                continue
            next_start = blocks[i + 1][0] if i + 1 < len(blocks) else len(self.mm)
            if verse_offset < next_start:
                return bs

        return None

    _F8_OFFSET = 0x6000  # F8 page table location (raw 2-byte slots)
    _F8_RAW_COUNT = 22534  # Total 2-byte slots at 0x6000 (from header pair 8)
    _F8_FIRST_VERSE = 299  # Verse index corresponding to F8 entry 0 (ESVS-specific; differs per module)

    # Per-module F8 first verse override (Session 59)
    _F8_FIRST_VERSE_PER_MODULE = {
        "KJVS": 299,  # Gen 12:1 = verse 299 (Gen1→31,Gen2→25,...,Gen11→32 = 299 cumulative)
        # ESVS: 299 (default)
    }

    # Per-module F8 interleaving (Session 55)
    # Some modules have interleaved F8 tables where even indices are ESVS
    # placeholders (type=0x00) and odd indices are the module's entries.
    # offset=0 (default, ESVS): flat access; offset=1 (KJVS): skip even entries
    _F8_INTERLEAVE = {
        "KJVS": 1,
    }

    _KJVS_PAGE_SIZE = (
        0x100  # KJVS page table entry multiplier (u16 * 0x100 → byte offset)
    )

    _WMAP_STRIDE = 128  # 128 bytes per primary/secondary entry
    _WORD_MAP_SLOTS = 64  # 128 bytes / 2 bytes per uint16
    _WMAP_ENTRY_OFFSET = 0  # Entry index = verse_idx (empirically: 299→Gen 12:1)

    # Per-module WMAP section offsets (Session 54)
    # Format: (primary_start_offset, secondary_start_offset)
    # ESVS has 4-byte count header at 0x66F500, primary at 0x66F504
    # KJVS has no count header; primary starts directly at 0x553E00
    _WMAP_OFFSETS = {
        "ESVS": (0x66F504, 0x92F804),
        "KJVS": (0x553E00, 0x814100),
        # "NAS95S": (TBD, TBD),  # Offset discovery pending
    }

    # Per-module pair/diff table offsets (Session 26, 30)
    # Format: (pair_offset, varlen_offset)
    # ESVS offsets verified; KJVS/NAS95S offsets pending discovery
    _DIFF_OFFSETS = {
        "ESVS": (0xEB5608, 0xEE1638),
        # "KJVS": (TBD, TBD),
        # "NAS95S": (TBD, TBD),
    }

    def _load_word_map(self):
        """Load word-position -> u16_lo mapping from word map pages.

        Session 24 architecture (from Ghidra decompilation of FUN_1002e2b20):
          - File handle is positioned by module init (NOT at 0x2B0100)
          - Per-module _WMAP_OFFSETS dict maps module abbrev to (primary, secondary)
          - ESVS: count header at 0x66F500, primary at 0x66F504, secondary at 0x92F804
          - KJVS: primary at 0x553E00 (no count header), secondary at 0x814100
          - Each entry: primary[i] (128 bytes) + secondary[i] (128 bytes)
          - Encoding format: TDiffWordMap delta-encoded (FUN_1009e7e60 uses
            128-bit multiply-accumulate arithmetic for lookup)
          - NOT a flat uint16 array; decoding requires TDiffWordMap logic

        F8 table at 0x6000 maps verse indices (BE uint16) to format types (lo byte).
        Entry index = verse_idx - _WMAP_ENTRY_OFFSET (empirically: verse 299 → entry 297).

        The Pool+KT1 resolver for u16_lo → Strong's is confirmed working.
        Word-position → u16_lo mapping is NOT YET IMPLEMENTED (blocked on
        TDiffWordMap decode logic).

        Tables A+B are also loaded for reference (sorted u16_lo vocabulary).
        """
        if hasattr(self, "_word_map_loaded"):
            return

        # Apply per-module F8 first verse override (Session 59)
        abbr = self.module_abbr or self.abbrev
        self._f8_first_verse = self._F8_FIRST_VERSE_PER_MODULE.get(
            abbr, self._F8_FIRST_VERSE
        )

        self._table_a_u16 = []
        self._table_b_u16 = []

        # Load Tables A+B (sorted u16_lo vocabulary, for reference)
        off = 0xC9F8
        while off + 4 <= len(self.mm):
            prefix = self.mm[off]
            if prefix not in (0x00, 0x01) or self.mm[off + 3] != 0x00:
                break
            type_byte = self.mm[off + 1]
            byte2 = self.mm[off + 2]
            u16_lo = (type_byte << 8) | byte2
            if prefix == 0x00:
                self._table_a_u16.append(u16_lo)
            else:
                self._table_b_u16.append(u16_lo)
            off += 4

        # Load T1 cumulative word positions
        # Per-module T1 offsets: (count_offset, data_offset, gap_bytes)
        _T1_OFFSETS = {
            "ESVS": (0x05233C, 0x052342),
            "KJVS": (0x7F591A, 0x7F591C),
        }
        self._t1 = []
        abbr = self.module_abbr or self.abbrev
        entry = _T1_OFFSETS.get(abbr)
        if entry is not None:
            t1_count_off, t1_data_off = entry
            ndt_count = struct.unpack_from(">H", self.mm, t1_count_off)[0]
        else:
            # Fallback: ESVS offsets
            t1_count_off = 0x05233C
            ndt_count = struct.unpack_from(">H", self.mm, t1_count_off)[0]
            t1_data_off = 0x052342
        for i in range(ndt_count):
            v = struct.unpack_from(">I", self.mm, t1_data_off + i * 4)[0]
            self._t1.append(v)
            # Stop if values drop significantly (table transition),
            # but load at least min_entries to cover Revelation
            if len(self._t1) > 1000 and v < self._t1[-2] // 10:
                # Value dropped by >10x — likely end of meaningful data
                # But only stop if we've loaded enough for the full canon
                if len(self._t1) > ndt_count - 100:
                    break

        # Load F8 page type table (raw, including interleaved modules)
        abbr = self.module_abbr or self.abbrev
        _raw_f8_types = []
        _raw_f8_params = []
        for i in range(self._F8_RAW_COUNT):
            off = self._F8_OFFSET + i * 2
            _raw_f8_types.append(self.mm[off])  # lo byte = page type
            _raw_f8_params.append(self.mm[off + 1])  # hi byte = page param

        # Extract effective F8 entries (skip interleaved placeholders)
        interleave = self._F8_INTERLEAVE.get(abbr, 0)
        if interleave != 0:
            self._f8_types = []
            self._f8_params = []
            for i in range(interleave, self._F8_RAW_COUNT, 2):
                self._f8_types.append(_raw_f8_types[i])
                self._f8_params.append(_raw_f8_params[i])
            self._f8_effective_count = len(self._f8_types)
            self._f8_default_byte_order_le = True  # odd entries all non-zero -> LE
        else:
            self._f8_types = _raw_f8_types
            self._f8_params = _raw_f8_params
            self._f8_effective_count = self._F8_RAW_COUNT
            self._f8_default_byte_order_le = True  # ESVS types are non-zero -> LE

        # Word map section: primary + secondary arrays (per-module offsets)
        wmap_offsets = self._WMAP_OFFSETS.get(abbr)
        if wmap_offsets is not None:
            self._wmap_primary_offset = wmap_offsets[0]
            self._wmap_secondary_offset = wmap_offsets[1]
        else:
            # Fallback: ESVS layout (4-byte count header before primary)
            self._wmap_primary_offset = 0x66F504
            self._wmap_secondary_offset = 0x92F804
        self._wmap_count = self._F8_RAW_COUNT  # WMAP has full count (not interleaved)

        self._WORD_MAP_SLOTS = 64  # 128 bytes / 2 bytes per uint16

        # Get per-module diff table offsets
        diff_offsets = self._DIFF_OFFSETS.get(abbr)
        pair_offset = diff_offsets[0] if diff_offsets else self._DIFF_OFFSETS["ESVS"][0]

        # Load per-verse pair table (marker_a, marker_b, nm, ni per entry)
        # and cumulative var-len offsets for real entries (marker_b == 0)
        self._pair_nm = [0] * self._wmap_count
        self._pair_ni = [0] * self._wmap_count
        self._pair_is_real = [False] * self._wmap_count
        self._pair_cum_offset = [
            0
        ] * self._wmap_count  # cumulative nm+ni before this entry
        cum = 0
        for i in range(self._wmap_count):
            p = struct.unpack_from("<4H", self.mm, pair_offset + i * 8)
            self._pair_nm[i] = p[2]
            self._pair_ni[i] = p[3]
            self._pair_is_real[i] = p[1] == 0
            self._pair_cum_offset[i] = cum
            if p[1] == 0:
                cum += p[2] + p[3]

        self._word_map_loaded = True

    def _get_word_map_entry(self, verse_idx: int) -> tuple[int, int, int] | None:
        """Return (primary_offset, secondary_offset, byte_order_le) for T1 verse_idx.

        primary_offset/secondary_offset are byte offsets of the 128-byte entries.
        byte_order_le is True for LE, False for BE (from F8 type byte).

        Returns None if verse_idx is outside the word map range.
        """
        if verse_idx < getattr(self, "_f8_first_verse", self._F8_FIRST_VERSE):
            return None

        page_index = verse_idx - getattr(self, "_f8_first_verse", self._F8_FIRST_VERSE)

        if page_index < self._f8_effective_count:
            f8_type = self._f8_types[page_index]
            byte_order_le = f8_type != 0x00
        else:
            # Verse beyond F8 effective range; use module default byte order
            if page_index >= self._wmap_count:
                return None
            byte_order_le = self._f8_default_byte_order_le

        entry_idx = verse_idx - self._WMAP_ENTRY_OFFSET
        if entry_idx < 0 or entry_idx >= self._wmap_count:
            return None

        primary_off = self._wmap_primary_offset + entry_idx * self._WMAP_STRIDE
        secondary_off = self._wmap_secondary_offset + entry_idx * self._WMAP_STRIDE

        return (primary_off, secondary_off, byte_order_le)

    def _get_verse_u16_list(self, verse_idx: int) -> list[int] | None:
        """Return list of u16_lo values for a verse's Strong's entries, in word order.

        Decodes the TDiffWordMap delta-encoded primary+secondary arrays:
        - Primary: 64 uint16 LE values (dictionary page of u16_lo values)
        - Secondary: 64 byte indices (0-63 valid, stored as uint16 with 0x00 padding)
        - Groups in secondary (separated by value drops) represent annotation types
        - Group 0 = Strong's numbers (primary use case)
        - Indices >= 64 reference the NEXT entry's primary (index - 64)

        Per-verse diff table (Session 26, 30):
        - Pair table at 0xEB5608: nm/ni per verse
        - Var-len data at 0xEE1638: nm+ni bytes per real verse (cumulative)
        - nm bytes encode additional word indices (format TBD by TDiffWordMap decode)

        Ghidra decompilation (FUN_1002bec80, FUN_1002be080, FUN_1009e7e60):
        - numMatch at struct+0x20 (primary entries per verse/group)
        - numInvMatch at struct+0x24 (secondary entries)
        - Lookup uses 128-bit SIMD binary search on sorted secondary->primary table
        """
        self._load_word_map()

        entry = self._get_word_map_entry(verse_idx)
        if entry is None:
            return None
        primary_off, secondary_off, byte_order_le = entry

        # Read primary (64 uint16 values; LE if byte_order_le else BE)
        prim_data = self.mm[primary_off : primary_off + self._WMAP_STRIDE]
        prim_fmt = "<" if byte_order_le else ">"
        prim_le = list(
            struct.unpack_from(f"{prim_fmt}{self._WORD_MAP_SLOTS}H", prim_data, 0)
        )

        # Read secondary (64 byte values, stored as uint16 BE with 0x00 high byte)
        sec_data = self.mm[secondary_off : secondary_off + self._WMAP_STRIDE]
        sec_bytes = [
            struct.unpack_from(">H", sec_data, i)[0]
            for i in range(0, self._WMAP_STRIDE, 2)
        ]

        # Also read the NEXT entry's primary for indices >= 64
        next_entry_idx = (verse_idx - self._WMAP_ENTRY_OFFSET) + 1
        next_prim_le = []
        if next_entry_idx < self._wmap_count:
            next_prim_off = (
                self._wmap_primary_offset + next_entry_idx * self._WMAP_STRIDE
            )
            next_prim_data = self.mm[next_prim_off : next_prim_off + self._WMAP_STRIDE]
            next_prim_le = list(
                struct.unpack_from(
                    f"{prim_fmt}{self._WORD_MAP_SLOTS}H", next_prim_data, 0
                )
            )

        # Find groups (value drops)
        groups = []
        current = [sec_bytes[0]]
        for i in range(1, self._WORD_MAP_SLOTS):
            if sec_bytes[i] < sec_bytes[i - 1]:
                groups.append(current)
                current = [sec_bytes[i]]
            else:
                current.append(sec_bytes[i])
        groups.append(current)

        # Group 0 = Strong's numbers (first group)
        if not groups:
            return []

        strongs_group = groups[0]
        u16_list = []
        for idx in strongs_group:
            if idx < self._WORD_MAP_SLOTS:
                u16_list.append(prim_le[idx])
            elif idx < self._WORD_MAP_SLOTS * 2 and next_prim_le:
                # NEXT entry's primary
                u16_list.append(next_prim_le[idx - self._WORD_MAP_SLOTS])
            elif idx < self._WORD_MAP_SLOTS * 4:
                # Cross-verse up to 2 entries ahead
                n2_idx = next_entry_idx + 1
                slot = idx % self._WORD_MAP_SLOTS
                if n2_idx < self._wmap_count:
                    n2_off = self._wmap_primary_offset + n2_idx * self._WMAP_STRIDE
                    n2_prim = list(
                        struct.unpack_from(
                            f"{prim_fmt}{self._WORD_MAP_SLOTS}H", self.mm, n2_off
                        )
                    )
                    u16_list.append(n2_prim[slot])

        # Note: the per-verse diff table (nm/ni bytes at varlen_offset) encodes
        # word-position delta data for TDiffWordMap lookup, NOT additional prim
        # indices for Strong's numbers. Do not interpret diff bytes as prim indices.

        return u16_list

    def _get_word_u16_lo(self, word_position: int) -> int | None:
        """Return the u16_lo for a given global word position.

        word_position is the cumulative word count from T1 (0 = first word of Gen 1:1).
        Returns the u16_lo if the word has a Strong's entry, or None.

        The TDiffWordMap encoding uses per-verse primary/secondary 128-byte entries:
        - Primary: 64 uint16 LE dictionary of u16_lo values
        - Secondary: 64 byte indices (grouped by annotation type)
        - Group 0 = Strong's numbers, ordered by word position within the verse
        - The first entry corresponds to the first annotated word, second to second, etc.

        Because exact word-position→entry mapping requires additional per-word
        position data (numMatch/numInvMatch), this method returns None.
        Use get_verse_strongs_list() for per-verse Strong's resolution.
        """
        return None

    def _get_word_u16_lo_legacy(self, word_position: int) -> int | None:
        """Legacy decoder (pre-Session 24) using 0x2B0100 page base - deprecated."""
        return None

    # Per-module Genesis 1:1 entry block / page base offsets (ESVS-only for now)
    _GEN1_PAGE_BASES = {
        "ESVS": 0xA3CE00,
        # KJVS/NAS95S: Genesis 1:1 at different offsets; need per-module discovery
    }

    def get_verse_with_strongs(self, reference: str) -> tuple[str, list[int]] | None:
        """Return (verse_text, word_ids) for a reference.

        word_ids are the uint24 BE values from word map lookup.
        Uses entry blocks for Gen 1:1 and F8 page-based word map for later verses.
        """
        ref = parse_reference(reference)
        if ref is None:
            return None

        book, chapter, verse = ref
        book_normalized = _normalize_book_name(book, self._known_books)
        if book_normalized is None:
            return None

        offset = self._find_verse_offset(book_normalized, chapter, verse)
        if offset is None:
            return None

        text = self._extract_verse_text(offset)

        # Gen 1:1 has its own entry block
        page_base = self._find_page_base(offset)
        abbr = self.module_abbr or self.abbrev
        gen1_base = self._GEN1_PAGE_BASES.get(abbr, self._GEN1_PAGE_BASES.get("ESVS"))
        if (
            page_base is not None
            and page_base == gen1_base
            and book_normalized == "Genesis"
            and chapter == 1
            and verse == 1
        ):
            word_ids = self._find_entry_block(page_base)
            return text, word_ids if word_ids else []

        # For other verses, attempt word map lookup via F8 pages
        self._load_word_map()
        self._build_cumulative_index()

        # Find T1 verse index for this book/chapter/verse
        verse_idx = self._find_t1_verse_index(book_normalized, chapter, verse)
        if verse_idx is None:
            return text, []

        # Get u16_lo values via TDiffWordMap decoder
        word_ids = self._get_verse_u16_list(verse_idx) or []

        # Supplement with triples if available (~60% additional coverage)
        self._load_triples()
        if hasattr(self, "_triples_cumul") and self._triples_cumul is not None:
            triples_u16 = self._get_verse_triples_u16_list(verse_idx)
            if triples_u16:
                word_ids = list(
                    dict.fromkeys(word_ids + triples_u16)
                )  # merge, dedupe, preserve order

        return text, word_ids

    # -- Triples infrastructure (Session 56+) ---------------------------------
    # Triples provide the remaining ~60% of Strong's coverage beyond WMAP.
    # Cumulatives are constructed at runtime by be610 (+0x30); triples data
    # at +0x40. Offsets must be captured via Frida hooking.
    #
    # Format: cumulatives[v] = cumulative triple count through verse v
    #         triples entry = 3 bytes: (delta_u8, param1_u8, param2_u8)
    #         u16_lo = delta + accumulated_base (running sum)
    #
    # Per-module triples file offsets (populate when captured):
    _TRIPLES_OFFSETS = {
        # Format: (cumulatives_offset, triples_offset, cumul_format, cumul_count)
        # cumul_format: "uint8" | "uint32_be"
        # cumul_count: number of cumulatives entries (optional)
        "KJVS": (0x490A4, 0x6786C, "uint32_be", 31218),
        # NOTE: triples_raw at 0x6786C (930522*3 bytes) requires SIMD decode
        # (c2dd0/vtbl+0x100) to resolve to u16_lo pool indices.
        # Set _triples_simd_decode=True and implement decode path, or
        # capture pre-decoded u16_lo values via Frida at runtime.
    }

    def _load_triples(self):
        """Auto-load triples data from module file using _TRIPLES_OFFSETS."""
        if hasattr(self, "_triples_cumul") and self._triples_cumul is not None:
            return

        abbr = self.module_abbr or self.abbrev
        entry = self._TRIPLES_OFFSETS.get(abbr)
        if entry is None:
            return

        cumul_off, triples_off, cumul_fmt, cumul_cnt = entry
        with open(self.filepath, "rb") as f:
            if cumul_cnt is None:
                cumul_cnt = 22534
            cumul_size = cumul_cnt * (
                4 if cumul_fmt == "uint32_be" else 2 if "uint16" in cumul_fmt else 1
            )
            f.seek(cumul_off)
            cumul_raw = f.read(cumul_size)

            triples_raw = b""
            if triples_off is not None:
                # For 3-byte triples, total triples = last cumulatives value
                import struct

                if cumul_fmt == "uint32_be":
                    total = struct.unpack_from(">I", cumul_raw, (cumul_cnt - 1) * 4)[0]
                elif cumul_fmt == "uint16_be":
                    total = struct.unpack_from(">H", cumul_raw, (cumul_cnt - 1) * 2)[0]
                else:
                    total = cumul_raw[cumul_cnt - 1]
                f.seek(triples_off)
                triples_raw = f.read(total * 3)

            self._set_triples_data(cumul_raw, triples_raw, cumul_fmt, cumul_cnt)

    def _set_triples_data(
        self,
        cumulatives: bytes | list,
        triples_data: bytes | list,
        cumul_format: str = "uint32_be",
        cumul_count: int | None = None,
    ):
        """Set triples data from raw bytes for runtime-captured data.

        cumulatives: raw bytes or list of cumulative values (per-verse, non-decreasing)
        triples_data: raw bytes of triples entries (3 bytes each)
        cumul_format: 'uint8' for 1-byte cumulatives, 'uint32_be' for 4-byte BE
        cumul_count: number of cumulatives entries (auto-detected if None)
        """
        import struct

        if isinstance(cumulatives, bytes):
            if cumul_format == "uint8":
                self._triples_cumul = (
                    list(cumulatives[:cumul_count])
                    if cumul_count
                    else list(cumulatives)
                )
            elif cumul_format == "uint32_be":
                if cumul_count is None:
                    cumul_count = len(cumulatives) // 4
                self._triples_cumul = [
                    struct.unpack_from(">I", cumulatives, i * 4)[0]
                    for i in range(cumul_count)
                ]
            else:
                raise ValueError(f"Unknown cumulatives format: {cumul_format}")
        else:
            self._triples_cumul = list(cumulatives)

        if isinstance(triples_data, bytes):
            self._triples_raw = triples_data
        else:
            self._triples_raw = bytes(triples_data)

        self._triples_count = len(self._triples_cumul)
        self._triples_total = self._triples_cumul[-1] if self._triples_cumul else 0

    def _get_verse_triples_u16_list(self, verse_idx: int) -> list[int] | None:
        """Return u16_lo values from triples for a verse at T1 index verse_idx.

        Uses pre-loaded cumulatives+triples set via _set_triples_data().
        For KJVS (SIMD decode): groups triples by sentinels (00,03,XX) and
        decodes each group via _decode_triples_simd().
        For delta-encoded triples: byte 0 is delta_u8, accumulated across verse.
        """
        if not hasattr(self, "_triples_cumul") or self._triples_cumul is None:
            return None

        cumul = self._triples_cumul
        if verse_idx < 0 or verse_idx >= len(cumul):
            return None

        start = 0 if verse_idx == 0 else cumul[verse_idx - 1]
        end = cumul[verse_idx]

        if start > end or end > self._triples_total:
            return None
        if start == end:
            return []

        abbr = self.module_abbr or self.abbrev
        use_simd = abbr == "KJVS"  # KJVS uses c2dd0 SIMD decode

        if use_simd:
            return self._decode_triples_simd(verse_idx, start, end)

        use_delta = getattr(self, "_triples_delta", False)
        u16_list = []
        acc = 0
        for i in range(start, end):
            off = i * 3
            if off + 2 > len(self._triples_raw):
                break
            if use_delta:
                acc += self._triples_raw[off]
                u16_lo = acc
            else:
                u16_lo = self._triples_raw[off] | (self._triples_raw[off + 1] << 8)
            u16_list.append(u16_lo)

        return u16_list

    def _decode_triples_simd(self, verse_idx: int, start: int, end: int) -> list[int]:
        """Decode KJVS triples via byte-pair and KT1 direct index resolution.

        The triples entries are 3-byte records: (delta_u8, param1_u8, param2_u8).
        Sentinel entries (00, 03, XX) separate groups. Each non-sentinel entry
        maps to one word's Strong's number.

        For each non-sentinel entry, tries:
        - Byte-pair LE (d | p1<<8) → validate via _resolve_strongs()
        - Byte-pair BE (p1 | d<<8) → validate via _resolve_strongs()
        - Each byte (d, p1, p2) as KT1 direct index → validate via _resolve_strongs()

        Returns u16_lo values that successfully resolve to Strong's numbers.
        When an entry produces no matches, it is skipped (partial coverage).

        Session 59: replaced WMAP-based vtbl emulation (non-functional for
        Gen 12:1) with byte-pair heuristic providing best-effort coverage.
        """
        if not hasattr(self, "_triples_raw"):
            return []

        raw = self._triples_raw
        u16_list = []

        # Build groups separated by sentinels (00, 03, XX)
        groups = []  # list of lists of (delta, param1, param2)
        current: list[tuple[int, int, int]] = []
        for i in range(start, end):
            off = i * 3
            d, p1, p2 = raw[off], raw[off + 1], raw[off + 2]
            if d == 0x00 and p1 == 0x03:
                if current:
                    groups.append(current)
                    current = []
            else:
                current.append((d, p1, p2))
        if current:
            groups.append(current)

        # For each non-sentinel entry in each group, try decoding
        for group in groups:
            for d, p1, p2 in group:
                # Try byte-pair combinations (LE/BE)
                le_pair = d | (p1 << 8)
                be_pair = p1 | (d << 8)

                for u16_val in (le_pair, be_pair):
                    if u16_val > 0 and u16_val not in u16_list:
                        resolved = self._resolve_strongs(u16_val)
                        if resolved:
                            u16_list.append(u16_val)

                # Try each byte as KT1 direct index
                for b in (d, p1, p2):
                    if b > 0 and b not in u16_list:
                        resolved = self._resolve_strongs(b)
                        if resolved:
                            u16_list.append(b)

        return u16_list

    def _find_t1_verse_index(self, book: str, chapter: int, verse: int) -> int | None:
        """Find the T1 verse index for a given book/chapter/verse.

        T1 entries are in Bible canon order. Each T1 entry is a cumulative
        word count for one verse. This method maps a reference to its T1 index.
        """
        self._build_cumulative_index()
        self._load_word_map()
        vdata = self._verse_counts.get(book)
        if not vdata or chapter > len(vdata):
            return None

        verses_in_ch = vdata[chapter - 1]
        if verse < 1 or verse > verses_in_ch:
            return None

        # Cumulative verses before this chapter
        cum_before_ch = sum(vdata[: chapter - 1])

        # Compute verse index within book
        verse_in_book = cum_before_ch + verse - 1

        # Find T1 index by scanning books in canon order
        cum_verses = 0
        for bk in self._books:
            if bk == book:
                break
            vd = self._verse_counts.get(bk)
            if vd:
                cum_verses += sum(vd)
        else:
            # Book not found in canon, try _known_books
            for bk in sorted(self._known_books):
                if bk == book:
                    break
                vd = self._verse_counts.get(bk)
                if vd:
                    cum_verses += sum(vd)

        t1_idx = cum_verses + verse_in_book
        if t1_idx < 0 or t1_idx >= len(self._t1):
            return None

        return t1_idx

    def get_verse_strongs_list(
        self, reference: str
    ) -> list[tuple[int, list[str], str]] | None:
        """Return [(u16_lo, strongs_list, word_type), ...] for a verse.

        strongs_list contains ALL Strong's numbers the u16_lo maps to
        (pool blocks 2-14 + KT1 fallback). When the same u16_lo appears
        in multiple blocks, all matches are returned.
        word_type is 'H' for Hebrew, 'G' for Greek, or '' if unknown.
        Uses F8 page-based word map for verses beyond Gen 1:1.
        """
        result = self.get_verse_with_strongs(reference)
        if result is None:
            return None

        _text, word_ids = result
        strongs_list: list[tuple[int, list[str], str]] = []
        for u16_lo in word_ids:
            strongs = self._resolve_strongs(u16_lo)
            if strongs:
                wtype = strongs[0][0] if strongs else ""
                strongs_list.append((u16_lo, strongs, wtype))

        return strongs_list

    def _get_kt1(self) -> list[str]:
        """Build (or return cached) KT1 Strong's key list from the binary file.

        Scans the KT1 region for 5/6-byte ASCII entries beginning with G/H/C.
        Returns a list of strings like 'H0430', 'G0001', etc.
        """
        if not hasattr(self, "_kt1_cache"):
            cfg = _get_strongs_config(self.module_abbr or self.abbrev)
            if cfg is None:
                self._kt1_cache: list[str] = []
                return self._kt1_cache

            kt1_start = cfg[0]
            keys: list[str] = []
            pos = kt1_start
            end_guess = kt1_start + 0x20000  # generous upper bound
            while pos < min(end_guess, len(self.mm)):
                ch = chr(self.mm[pos])
                if ch in "GHC":
                    if all(0x30 <= self.mm[pos + i] <= 0x39 for i in range(1, 5)):
                        key = ch + "".join(chr(self.mm[pos + i]) for i in range(1, 5))
                        if (
                            pos + 5 < len(self.mm)
                            and chr(self.mm[pos + 5]).isalpha()
                            and chr(self.mm[pos + 5]).islower()
                        ):
                            key += chr(self.mm[pos + 5])
                            pos += 6
                        else:
                            pos += 5
                        keys.append(key)
                        continue
                # Stop if we've found entries and hit a region of zeros (end of KT1)
                if keys and all(b == 0 for b in self.mm[pos : pos + 8]):
                    break
                pos += 1
            self._kt1_cache = keys
        return self._kt1_cache

    def _resolve_strongs(self, u16_lo: int) -> List[str]:
        """Resolve a word token u16_lo to ALL possible Strong's number strings.

        Uses per-module configuration for KT1, Pool, block layout, and ktstart values.
        Binary-searches the pool for u16_lo within each block.
        Supports per-block ktstart overrides (e.g., block 2 = NT Greek).

        Returns a list of Strong's numbers (e.g. ['H3068', 'H2377']).
        When the same u16_lo appears in multiple pool blocks, ALL matching
        Strong's numbers are returned. Callers should disambiguate using
        verse context (e.g., prefer H-prefix for OT verses, G-prefix for NT).

        Fallback (Session 59): If u16_lo is not found in any pool block,
        checks if u16_lo is a valid direct KT1 index. The triples SIMD decode
        produces a mix of pool u16_lo values and KT1 direct indices (for
        Strong's numbers not covered by blocks 2-14, e.g., H0001 at KT1[5514]).

        Returns an empty list if u16_lo is unrecognized.
        """
        cfg = _get_strongs_config(self.module_abbr or self.abbrev)
        if cfg is None:
            return []

        kt1_start, pool_start, pool_swap, ktstart_base, ktstart_slope, block_sizes = (
            cfg[:6]
        )
        ktstart_overrides = cfg[6] if len(cfg) > 6 else {}
        kt1 = self._get_kt1()

        results: List[str] = []
        block_start_global = 0
        for blk_id, blk_count in block_sizes:
            # Binary search this block for u16_lo
            lo, hi = 0, blk_count - 1
            while lo <= hi:
                mid = (lo + hi) // 2
                addr = pool_start + (block_start_global + mid) * 4
                if pool_swap:
                    v = struct.unpack_from(">H", self.mm, addr + 2)[0]  # u16_lo at +2
                else:
                    v = struct.unpack_from(">H", self.mm, addr)[0]
                if v == u16_lo:
                    ktstart = ktstart_overrides.get(
                        blk_id,
                        _kt1_start_for_block(blk_id, ktstart_base, ktstart_slope),
                    )
                    kt_idx = ktstart + mid
                    if 0 <= kt_idx < len(kt1):
                        results.append(kt1[kt_idx])
                    break
                elif v < u16_lo:
                    lo = mid + 1
                else:
                    hi = mid - 1
            block_start_global += blk_count

        # Fallback: u16_lo as direct KT1 index (Session 59)
        # Triples SIMD decode produces KT1 indices for Strong's numbers
        # not covered by pool blocks 2-14 (e.g., H0001 at KT1[5514],
        # H4138 at KT1[9640], H7200 at KT1[12700]).
        if not results and 0 <= u16_lo < len(kt1):
            candidate = kt1[u16_lo]
            if candidate and candidate[0] in "GHC":
                results.append(candidate)

        return results

    def close(self):
        super().close()
