"""
Lexicon / dictionary module parser for Accordance `.atool` files (types 3 & 4).

Handles BDAG (Greek, type 3), HALOT (Hebrew, type 4), and similar lexicon modules.
"""

import re
from pathlib import Path
from typing import Optional

from .module import AccordanceModule
from .encoding import (
    decode,
    decode_always,
    normalize_headword,
    decode_hebrew,
    decode_hebrew_always,
    normalize_hebrew_headword,
    _postprocess_decoded,
)

HEBREW_CONSONANT_BYTES = frozenset(
    {
        0x61,  # a  aleph
        0x62,  # b  bet
        0x67,  # g  gimel
        0x64,  # d  dalet
        0x68,  # h  he
        0x77,  # w  vav
        0x7A,  # z  zayin
        0x78,  # x  chet
        0x66,  # f  tet (Accordance-specific; f=phi in Greek but tet in Hebrew)
        0x6A,  # j  yod
        0x79,  # y  yod (variant)
        0x6B,  # k  kaf
        0x6C,  # l  lamed
        0x6D,  # m  mem
        0x6E,  # n  nun
        0x73,  # s  samekh
        0x4F,  # O  ayin (uppercase)
        0x70,  # p  pe
        0x63,  # c  tsadi
        0x71,  # q  qof
        0x72,  # r  resh
        0x76,  # v  shin
        0x74,  # t  tav
        0x4B,  # K  final kaf
        0x4D,  # M  final mem
        0x4E,  # N  final nun
        0x50,  # P  final pe
        0x43,  # C  final tsadi
    }
)

# Bytes that are Hebrew diacritics (8-bit vowel points, dagesh, etc.)
_HEBREW_MARK_BYTES = frozenset(
    {
        0x81,
        0x87,
        0x8C,
        0x8E,
        0x94,
        0x9F,
        0xA8,
        0xAB,
        0xAC,
        0xB4,
        0xB6,
        0xBD,
        0xBF,
        0xC3,
        0xC4,
        0xC9,
        0xD7,
        0xE4,
        0xEB,
        0xF2,
        0xF6,
    }
)

# Uppercase vowel marker bytes (A, E, I, O, U)
_HEBREW_VOWEL_UPPER = frozenset({0x41, 0x45, 0x49, 0x4F, 0x55})

# Uppercase form/variant marker bytes (appear within Hebrew headwords)
_HEBREW_FORM_UPPER = frozenset(
    {
        0x44,  # D
        0x46,  # F
        0x4A,  # J
        0x51,  # Q
        0x52,  # R
        0x53,  # S
        0x54,  # T
        0x56,  # V
        0x58,  # X
        0x5A,  # Z
    }
)


def _has_hebrew_pattern(hw_bytes: bytes) -> bool:
    """Check if a headword contains a valid Hebrew consonant+diacritic pattern.

    Returns True if the bytes look like an encoded Hebrew word (consonants
    alternating with vowel marks), as opposed to English text with accidental
    high bytes.
    """
    # Count consonant bytes and mark bytes
    consonants = sum(1 for b in hw_bytes if b in HEBREW_CONSONANT_BYTES)
    marks = sum(1 for b in hw_bytes if b >= 0x80 or b in _HEBREW_VOWEL_UPPER)

    if consonants == 0:
        return False

    # Unpointed Hebrew: consonants only, no vowel marks.
    # Valid for short headwords (e.g., 'ab' = אב, father).
    # Single-consonant entries (e.g., 'a' = א for the letter aleph) are
    # valid in some lexicons when they stand alone (1 byte).
    if marks == 0:
        return consonants >= 2 or (consonants == 1 and len(hw_bytes) == 1)

    # Check for consonant+mark alternation FIRST: at least one consonant
    # followed (within 3 bytes) by a mark byte (8-bit diacritic or uppercase
    # vowel marker).  This catches Hebrew words with sparse pointing (e.g.,
    # 4 consonants + 1 diacritic, ratio ~0.25) that would otherwise fall
    # below the ratio threshold.
    has_pattern = False
    for i in range(len(hw_bytes) - 1):
        if hw_bytes[i] in HEBREW_CONSONANT_BYTES:
            for j in range(i + 1, min(i + 4, len(hw_bytes))):
                if hw_bytes[j] >= 0x80 or hw_bytes[j] in _HEBREW_VOWEL_UPPER:
                    has_pattern = True
                    break
            if has_pattern:
                break

    if has_pattern:
        return True

    # Fallback: for entries without clear consonant+mark alternation, use a
    # mark-to-consonant ratio check.  Legitimate Hebrew headwords normally
    # show 0.3+ marks per consonant (vowel + dagesh combos).  English words
    # with accidental high bytes fall well below this threshold.
    mark_ratio = marks / max(consonants, 1)
    return mark_ratio >= 0.3


def _skip_hebrew_prefix(data: bytes, start: int) -> int:
    """Skip prefix characters before an encoded Hebrew headword.

    HALOT entries may have prefixes like ``* `` (root marker), ``I ``
    or ``II `` (homograph numbers), or ``cj. `` (conjecture marker)
    before the actual headword.
    """
    end = min(start + 20, len(data))
    i = start
    while i < end:
        b = data[i]
        # If we hit a Hebrew consonant byte, this is the headword start
        if b in HEBREW_CONSONANT_BYTES:
            return i
        # Allow common prefix characters: * I V c j . space
        if b in (0x2A, 0x20, 0x49, 0x56, 0x63, 0x6A, 0x2E, 0x29, 0x28):
            i += 1
            continue
        # If we hit a diacritic/high byte without seeing a consonant first,
        # assume the entry is malformed; try starting here anyway
        if b >= 0x80:
            return i
        break
    return start


_SUMMARY_LIMITS = {"brief": 150, "standard": 500, "full": None}


def _truncate_body(text: str, limit: int | None) -> str:
    """Truncate text at word boundary before `limit` chars. Appends '…' if truncated."""
    if limit is None or len(text) <= limit:
        return text
    cut = text[:limit].rsplit(" ", 1)[0] if " " in text[:limit] else text[:limit]
    return cut + "…"


class LexiconModule(AccordanceModule):
    """Parser for Accordance lexicon modules (.atool)."""

    def __init__(self, filepath: str | Path):
        super().__init__(filepath)
        self._entries: dict[str, list[int]] = {}
        self._is_hebrew = False
        self._is_english = False
        self._is_bdb = self.magic == 0x40
        self._is_km = False
        self._is_numbered = False
        self._is_utf8 = False
        self._is_tdot = False
        self._is_spicq = False
        self._is_tlot = False
        self._is_tdnt = self._detect_tdnt()
        self._is_webster = False  # Webster 1913 (binary word index format)
        self._is_ni = False  # NI Bible Dictionary (single-\\r delimited)
        self._is_biblewords = (
            False  # Bible Words / Names (binary word index + \\r\\r\\t body)
        )
        self._is_names = (
            False  # Names of God module (\\r\\r section headers, \\r entries)
        )
        self._bw_body_starts: list[int] = []  # Body text entry start positions
        self._format: str = ""
        self._tdot_article_bodies: list[tuple[int, int]] = []
        # Webster: sorted list of entry start offsets for boundary detection
        self._webster_entry_offsets: list[int] = []
        self._webster_body_start: int = 0
        self._build_index()

    @property
    def format(self) -> str:
        """Entry format type: 'standard', 'numbered', 'bdb', 'utf8', 'tdot', or 'trench'."""
        return self._format

    @property
    def language(self) -> str:
        """Primary language: 'Greek', 'Hebrew', or 'English'."""
        if self._is_hebrew:
            return "Hebrew"
        if self._is_english:
            return "English"
        return "Greek"

    def _detect_tdnt(self) -> bool:
        """Detect if this 0xFF2B module is TDNT (Greek NT) vs TDOT (Hebrew OT).

        TDNT and TDOT share the same magic bytes (0xFF2B) but use completely
        different entry formats and encodings.  Distinguish by module title
        or filename.
        """
        if self.magic != 0xFF2B:
            return False

        title = (self.title or "").lower()
        if "new testament" in title:
            return True

        # Also check abbreviation and filename for TDNT markers
        abbrev = (self.abbrev or "").lower()
        if "tdnt" in abbrev:
            return True

        fname = self.filepath.name.lower()
        if "tdnt" in fname or "new testament" in fname:
            return True

        # Check parent directory name
        parent = self.filepath.parent.name.lower()
        if "tdnt" in parent:
            return True

        return False

    # Known 2-byte magic → format mappings (Ghida-validated, Session 24)
    _MAGIC2_FORMAT = {
        0x20: "utf8",  # EDG (Etymological Dictionary of Greek)
        0x2E: "utf8",  # MGS (Brill Dictionary of Ancient Greek)
        0x2B: "tdot",  # TDOT / TDNT (standard lexicon with binary index)
    }

    # Regex patterns for entry_keys() key classification (compiled once)
    _STRONGS_RE = re.compile(r"^[HG]\d{1,4}$")
    _ENTRY_NUM_RE = re.compile(r"^\d{1,5}$")
    _HEBREW_RE = re.compile(r"[\u0590-\u05FF]")
    _GREEK_RE = re.compile(r"[\u0370-\u03FF\u1F00-\u1FFF]")
    _ASCII_WORD_RE = re.compile(r"^[A-Za-z ]{2,}$")

    def _detect_format(self) -> str:
        """Detect which entry format this module uses.

        Uses magic-byte-based dispatch with fallback checks for
        ambiguous cases. Returns 'bdb', 'numbered', 'utf8', 'tdot', or 'standard'.
        """
        if self._is_bdb:
            return "bdb"

        magic = self.magic

        # 2-byte magics (0xFFxx): second byte determines format
        if magic >= 0xFF00:
            magic2 = magic & 0xFF
            if magic2 in self._MAGIC2_FORMAT:
                return self._MAGIC2_FORMAT[magic2]
            # Unknown 2-byte magic: fall back to heuristic scan
            result = self._check_format(self.mm, start_pct=50)
            if result == "utf8":
                return "utf8"
            if self._check_tdot_format():
                return "tdot"
            return "standard"

        # KM Hebrew (0x4A) and Mounce Greek (0x3C): separate format with
        # \r\rgk-entry delimiters, not the standard numbered format.
        if magic in (0x3C, 0x4A):
            return "km"

        # Known numbered-format modules by magic byte
        if magic in (0x2E, 0x3B):
            return "numbered"

        # Ambiguous magic 0x33: HALOT (standard-Hebrew) or Thayer (numbered-Greek)
        # Ambiguous magic 0x39: NAS Greek (numbered-Greek) or NAS Hebrew (numbered-Hebrew)
        #
        # Session 25 disassembly + Session 26 Ghidra: FUN_1002c9f60 reads format
        # type at record+0x20 (uint16) via PosReadRecord from file position 0x100.
        # The record maps to the Pascal string + fixed header; the uint16 at
        # file+0x120 (first field of fixed header) is a version tag, not the
        # lexicon format type. FUN_100021e50 is a Mac OS Roman string decoder,
        # not a file opener. No sub-stream exists; the file handle is raw
        # NSFileHandle at physical offset 0. Heuristic scanning matches the
        # binary's dispatch for all known modules.
        if magic in (0x28, 0x33, 0x39, 0x44):
            return self._check_format(self.mm, start_pct=50)

        # Trench's Synonyms of the New Testament (magic 0x1D):
        # sections delimited by \r\r\r¤ (three CR + section symbol 0xA4)
        if magic == 0x1D:
            if self.mm.find(b"\r\r\r\xa4") >= 0:
                return "trench"
            return "standard"

        # Everything else: standard format
        return "standard"

    def _check_tdot_format(self) -> bool:
        """Check if a 0xFF-prefix module uses TDOT's custom entry format.

        TDOT articles use a distinct delimiter pattern:
        \\t\\xca\\r\\xca (tab-E-r-E) between the headword and article body.
        The headword is Accordance Hebrew-encoded.

        Samples entries from the text region (~35%) looking for this pattern.
        Returns True if >= 3 entries match the TDOT structure.
        """
        data = self.mm
        # Scan from ~35% (skip binary index in first third)
        start_off = max(len(data) * 35 // 100, 0x2000)
        TDOT_DELIM = b"\t\xca\r\xca"
        max_check = 1000
        matches = 0
        total = 0
        off = start_off

        while off < len(data) and total < max_check and matches < 5:
            p = data.find(b"\r\r", off)
            if p < 0:
                break
            after = p + 2
            if after + 60 > len(data):
                break
            total += 1

            b0 = data[after]
            # TDOT entries start with a Hebrew consonant (lowercase a-z)
            # followed by Hebrew markers (uppercase vowels/forms, high bytes)
            if not (0x61 <= b0 <= 0x7A):
                off = p + 2
                continue

            snippet = data[after : min(after + 300, len(data))]
            del_pos = snippet.find(TDOT_DELIM)
            if del_pos >= 5:
                # Check for Hebrew markers between start and delimiter
                headword_bytes = snippet[:del_pos]
                heb_score = sum(
                    1
                    for b in headword_bytes
                    if b in range(0x41, 0x5B)
                    or b >= 0x80
                    or b in (0x3B, 0x2C, 0x3A, 0x7B, 0x7D)
                )
                body_start = del_pos + len(TDOT_DELIM)
                # Scan past leading \r chars to find readable body text
                bs = (
                    snippet[body_start : body_start + 40]
                    if body_start < len(snippet)
                    else b""
                )
                text_start = 0
                while text_start < len(bs) and bs[text_start] < 0x20:
                    text_start += 1
                body_ascii = (
                    sum(
                        1 for b in bs[text_start : text_start + 20] if 0x20 <= b <= 0x7E
                    )
                    if text_start < len(bs)
                    else 0
                )
                if heb_score >= 2 and body_ascii >= 8:
                    matches += 1
            off = p + 2

        return matches >= 3

    def _check_format(self, data, start_pct: int = 10) -> str:
        """Check a sample of entries for UTF-8 or numbered format patterns.

        Scans from start_pct% of the file, checking up to 2000 entries.
        """
        start_off = max(len(data) * start_pct // 100, 0x2000)
        utf8_count = 0
        num_count = 0
        total_checked = 0
        off = start_off
        MAX_CHECK = 2000
        while (
            off < len(data) and utf8_count + num_count < 8 and total_checked < MAX_CHECK
        ):
            p = data.find(b"\r\r", off)
            if p < 0:
                break
            after = p + 2
            if after >= len(data):
                break
            total_checked += 1
            b0 = data[after]

            if b0 in (0xCE, 0xCF) and after + 1 < len(data):
                b1 = data[after + 1]
                if 0x80 <= b1 <= 0xBF:
                    utf8_count += 1
            elif 0x30 <= b0 <= 0x39:
                i = after
                while i < min(after + 6, len(data)) and 0x30 <= data[i] <= 0x39:
                    i += 1
                if i < len(data) and data[i] == 0x2E:
                    j = i + 1
                    # Louw & Nida format: XX.YY <headword>
                    # The '.' is followed by more digits (subdomain number),
                    # then a space, then the headword.
                    if j < len(data) and 0x30 <= data[j] <= 0x39:
                        k = j
                        while k < min(j + 5, len(data)) and 0x30 <= data[k] <= 0x39:
                            k += 1
                        if k < len(data) and data[k] == 0x20:
                            m = k + 1
                            if m < len(data) and data[m] in range(0x61, 0x7B):
                                snippet = data[m : min(m + 30, len(data))]
                                has_encoding = any(
                                    b >= 0x80 or b in _HEBREW_VOWEL_UPPER
                                    for b in snippet[:15]
                                )
                                if has_encoding:
                                    num_count += 1
                    else:
                        # Standard numbered format: <number>.  <headword>
                        while j < min(i + 4, len(data)) and data[j] == 0x20:
                            j += 1
                        if j < len(data) and data[j] in range(0x61, 0x7B):
                            snippet = data[j : min(j + 30, len(data))]
                            has_encoding = any(
                                b >= 0x80 or b in _HEBREW_VOWEL_UPPER
                                for b in snippet[:15]
                            )
                            if has_encoding:
                                num_count += 1
            off = p + 2

        if utf8_count >= 3 and utf8_count > num_count:
            return "utf8"
        if num_count >= 3 and num_count > utf8_count:
            return "numbered"
        return "standard"

    def _detect_hebrew(self) -> bool:
        """Detect Hebrew vs Greek encoding for numbered-format modules.

        Used for ambiguous magic bytes (0x33: HALOT/Thayer,
        0x39: NAS Greek/NAS Hebrew) and explicitly numbered modules
        (0x2E Greek Strong's, 0x3B Hebrew Strong's).
        """
        magic = self.magic

        # 2-byte magics: 0xFF2B = TDOT (Hebrew) or TDNT (Greek)
        if magic >= 0xFF00:
            if (magic & 0xFF) == 0x2B:
                if self._is_tdnt:
                    return False
                return True
            return False  # Other 2-byte lexicons are Greek

        # Known: 0x3B = Hebrew Strong's, 0x4A = KM Hebrew = Hebrew
        if magic in (0x3B, 0x4A):
            return True
        # Known: 0x2E = Greek Strong's, 0x3C = Mounce = Greek
        if magic in (0x2E, 0x3C):
            return False
        # Thayer: magic 0x33 with numbered format -> Greek
        if magic == 0x33:
            return False

        # Ambiguous: 0x39 (NAS Greek vs NAS Hebrew). Sample entries
        # by scanning forward from 40% past any binary index data.
        data = self.mm
        start_off = max(len(data) * 40 // 100, 0x2000)

        _heb_upper = _HEBREW_VOWEL_UPPER | _HEBREW_FORM_UPPER
        _heb_8bit = _HEBREW_MARK_BYTES

        heb_score = 0
        gr_score = 0
        checked = 0
        skipped = 0
        off = start_off

        while checked < 30 and off < len(data):
            p = data.find(b"\r\r", off)
            if p < 0:
                break
            start = p + 2
            if start >= len(data):
                break

            # Skip past entry number prefix
            hw_start = start
            i = start
            while i < min(start + 8, len(data)) and 0x30 <= data[i] <= 0x39:
                i += 1
            if i < len(data) and data[i] == 0x2E:
                i += 1
            while i < min(start + 12, len(data)) and data[i] == 0x20:
                i += 1
            hw_start = i

            if hw_start >= len(data):
                off = p + 2
                continue

            # Find headword end
            hw_end = None
            for i2 in range(hw_start, min(hw_start + 60, len(data))):
                b = data[i2]
                if b in (0x20, 0xA7, 0x2C, 0x3A, 0x3B, 0x09, 0x0D):
                    hw_end = i2
                    break
            if hw_end is None or hw_end <= hw_start:
                off = p + 2
                continue

            hw_bytes = data[hw_start:hw_end]
            if len(hw_bytes) < 1 or len(hw_bytes) > 60:
                off = p + 2
                continue

            # Skip if headword looks like binary data or pure English:
            # must contain Hebrew consonant bytes to be Hebrew.
            lower_count = sum(1 for b in hw_bytes if b in range(0x61, 0x7B))
            ctrl_count = sum(1 for b in hw_bytes if b < 0x20)
            high_count = sum(1 for b in hw_bytes if b >= 0x80)
            if ctrl_count > 0 or (high_count > 0 and lower_count == 0):
                skipped += 1
                if skipped > 50:
                    off = max(off + 0x10000, p + 0x10000)
                    skipped = 0
                else:
                    off = p + 2
                continue

            # Must look like an actual Hebrew headword to count as Hebrew.
            # English abbreviations like "ABBREVIATIONS" contain uppercase
            # letters that overlap with Hebrew marker bytes but lack the
            # consonant+mark alternation pattern of real Hebrew headwords.
            heb_consonants = sum(1 for b in hw_bytes if b in HEBREW_CONSONANT_BYTES)
            if heb_consonants == 0:
                off = p + 2
                continue
            if not _has_hebrew_pattern(hw_bytes):
                off = p + 2
                continue

            checked += 1
            upper_heb = sum(1 for b in hw_bytes if b in _heb_upper)
            heb_8 = sum(1 for b in hw_bytes if b in _heb_8bit)
            gr_8 = sum(
                1
                for b in hw_bytes
                if b
                in (
                    0xEE,
                    0xE1,
                    0xE0,
                    0xE2,
                    0xC0,
                    0xFA,
                    0xFB,
                    0xEF,
                    0xC1,
                    0xC8,
                    0xCF,
                    0xD7,
                    0xDD,
                    0xDA,
                    0xC6,
                    0xCE,
                )
            )

            if upper_heb > 0:
                heb_score += 3
            if heb_8 > gr_8:
                heb_score += 1
            elif gr_8 > heb_8:
                gr_score += 1

            off = p + 2

        return heb_score > gr_score

    def _read_plist_module_type(self) -> int:
        """Read the module type from the bundle's Info.plist.

        Returns the com.oaktree.module.moduletype value (3=Greek lexicon,
        4=Hebrew tool), or 0 if the plist can't be read.
        """
        import plistlib

        plist_path = self.filepath.parent / "Info.plist"
        try:
            with open(plist_path, "rb") as f:
                plist = plistlib.load(f)
            return plist.get("com.oaktree.module.moduletype", 0)
        except Exception:
            return 0

    def _detect_webster(self) -> bool:
        """Detect Webster 1913 binary word index format."""
        title = (self.title or "").lower()
        abbrev = (self.abbrev or "").lower()
        if "webster" in title or "webster" in abbrev:
            return True
        fname = self.filepath.name.lower()
        if "webster" in fname:
            return True
        parent = self.filepath.parent.name.lower()
        if "webster" in parent:
            return True
        return False

    # Accordance accent bytes used in Webster 1913 headwords.
    # These are stripped during normalization to produce searchable ASCII keys.
    _WEBSTER_ACCENT_BYTES = frozenset(
        {
            0xA0,  # non-breaking space (pronunciation spacing)
            0xB7,  # middle dot (syllable separator)
            0xC0,  # À (primary stress)
            0xC2,  # Â
            0xC8,  # È
            0xC9,  # É
            0xCA,  # Ê
            0xCB,  # Ë
            0xCC,  # Ì
            0xCD,  # Í
            0xCE,  # Î
            0xCF,  # Ï
            0xD4,
            0xD5,  # Ô Õ (quotes)
            0xE1,  # á (secondary stress)
        }
    )

    @staticmethod
    def _strip_webster_accents(hw_bytes: bytes) -> str:
        """Strip Accordance accent markers from a Webster headword.

        Returns the headword as lowercase ASCII, with all accent/diacritic
        bytes removed.  Only printable ASCII letters (a-z) are kept.
        """
        result = bytearray()
        for b in hw_bytes:
            if 0x61 <= b <= 0x7A:  # a-z (already lowercase)
                result.append(b)
            elif 0x41 <= b <= 0x5A:  # A-Z → lowercase
                result.append(b | 0x20)
            elif b == 0x2D:  # hyphen
                result.append(b)
            elif b in (0x2E, 0x27):  # period, apostrophe
                result.append(b)
            # All other bytes (accents, spaces, pronunciation chars) are stripped
        return result.decode("ascii", errors="replace").strip(".")

    @staticmethod
    def _is_webster_real_entry(data: bytes, after_rr: int) -> bool:
        """Check whether bytes at ``after_rr`` look like a real Webster entry.

        A real entry starts with an uppercase headword (A-Z, possibly with
        Accordance accent bytes mixed in) and is followed by a part-of-speech
        marker (``, n.``, ``, v.``, ``, a.``, ``, adj.``, ``, adv.``,
        ``, prep.``, ``(`` for pronunciation) or an etymology bracket ``[``.

        Returns False for internal text (quotes, Bible verses, cross-refs)
        that happen to follow \\r\\r but aren't real entry boundaries.
        """
        # Headword end: find first comma, space, (, \\r, or semicolon
        hw_region = data[after_rr : after_rr + 80]
        hw_end = 0
        for j in range(min(80, len(hw_region))):
            b = hw_region[j]
            if b in (0x2C, 0x20, 0x28, 0x0D, 0x3B, 0x09):
                if b == 0x2E:  # period — only stop if followed by space
                    if j + 1 < len(hw_region) and hw_region[j + 1] == 0x20:
                        hw_end = j + 1
                        break
                hw_end = j
                break

        if hw_end < 1:
            return False

        # Headword bytes (for stop-word check)
        hw_check = bytes(hw_region[:hw_end])
        # Reject common English words that appear as sentence-starts,
        # quote continuations, etc. — these are never real headwords.
        if hw_check in (
            b"A",
            b"I",
            b"In",
            b"And",
            b"To",
            b"The",
            b"We",
            b"Nor",
            b"An",
            b"It",
            b"But",
            b"For",
            b"So",
            b"With",
            b"From",
            b"On",
            b"At",
            b"By",
            b"Into",
            b"Upon",
            b"Or",
            b"He",
            b"She",
            b"His",
            b"Her",
            b"Its",
            b"Our",
            b"My",
            b"Thy",
            b"That",
            b"This",
            b"These",
            b"Those",
            b"There",
            b"Here",
            b"When",
            b"Where",
            b"What",
            b"Who",
            b"Which",
            b"How",
            b"Why",
            b"If",
            b"Then",
            b"Now",
            b"Thus",
            b"Yet",
            b"Though",
            b"Than",
            b"Till",
            b"Is",
            b"Are",
            b"Was",
            b"Were",
            b"Be",
            b"Being",
            b"Been",
            b"Have",
            b"Has",
            b"Had",
            b"Do",
            b"Does",
            b"Did",
            b"Can",
            b"Could",
            b"Will",
            b"Would",
            b"Shall",
            b"Should",
            b"May",
            b"Might",
            b"Must",
            b"Let",
            b"All",
            b"No",
            b"Not",
            b"Yes",
            b"Of",
            b"O",
            b"Oh",
        ):
            return False

        # Check body for part-of-speech or etymology markers
        body_start = after_rr + hw_end
        body = data[body_start : body_start + 30]
        has_pos = (
            b", n." in body
            or b", v." in body
            or b", a." in body
            or b", adj." in body
            or b", adv." in body
            or b", prep." in body
            or b", pron." in body
            or b", conj." in body
            or b", interj." in body
            or b"(n." in body
            or b"(v." in body
        )
        has_etym = b"[" in body[:5]
        has_pron = len(body) >= 3 and body[0] == 0x20 and body[1] == 0x28  # " (" start

        return has_pos or has_etym or has_pron

    @staticmethod
    def _extract_webster_headword(data: bytes, after_rr: int) -> str:
        """Extract and normalize a Webster headword from entry text.

        Reads from ``after_rr`` until the headword terminator, strips
        Accordance accent bytes, and returns a lowercase ASCII key.
        """
        hw_region = data[after_rr : after_rr + 80]
        hw_end = 0
        for j in range(min(80, len(hw_region))):
            b = hw_region[j]
            if b in (0x2C, 0x20, 0x28, 0x0D, 0x3B, 0x09):
                hw_end = j
                break
            if b == 0x2E:  # period — only end if followed by space
                if j + 1 < len(hw_region) and hw_region[j + 1] == 0x20:
                    hw_end = j
                    break

        if hw_end < 1:
            return ""

        hw_bytes = bytes(hw_region[:hw_end])
        key = LexiconModule._strip_webster_accents(hw_bytes)
        # Remove possessive 's suffix for search consistency
        if key.endswith("'s") and len(key) > 2:
            key = key[:-2]
        return key

    def _build_index_webster(self):
        """Build index for Webster 1913 dictionary.

        Webster stores ~107K dictionary definitions in a \\r\\r-delimited
        text region (roughly 41–68 MB into the file), embedded within a
        larger binary containing word-index tables and offset arrays.

        This method scans the definition-text region for real entries
        (headword + part-of-speech marker) and indexes each headword by
        its normalized lowercase-ASCII form.
        """
        data = self.mm

        # Locate the definition text start: scan forward from 35 MB to
        # find the first cluster of real entries (>= 5 within 20 KB).
        # The binary index occupies roughly the first 40 MB.
        SCAN_START = 0x2300000  # ~35 MB (before any known def text)
        pos = SCAN_START
        cluster_start = None
        cluster_count = 0
        body_start = None

        while pos < len(data) - 100:
            p = data.find(b"\r\r", pos)
            if p < 0:
                break
            after = p + 2
            if after >= len(data) - 2:
                break

            b0 = data[after]
            if b0 in range(0x41, 0x5B):  # A-Z
                if self._is_webster_real_entry(data, after):
                    if cluster_start is None or p - cluster_start > 20000:
                        cluster_start = p
                        cluster_count = 1
                    else:
                        cluster_count += 1
                        if cluster_count >= 5 and body_start is None:
                            body_start = cluster_start
            pos = p + 2

        if body_start is None:
            # Fallback: use a known-good starting offset
            body_start = 0x2740000

        self._webster_body_start = body_start

        # Scan for all real entries from body_start through the text region
        entries: dict[str, list[int]] = {}
        pos = body_start
        last_entry_pos = body_start
        gap_since_last = 0

        while pos < len(data) - 100:
            p = data.find(b"\r\r", pos)
            if p < 0:
                break
            after = p + 2
            if after >= len(data) - 2:
                break

            if gap_since_last > 0x200000:  # 2 MB gap → binary tail
                break

            b0 = data[after]
            if b0 not in range(0x41, 0x5B):
                pos = p + 2
                continue

            if not self._is_webster_real_entry(data, after):
                pos = p + 2
                continue

            key = self._extract_webster_headword(data, after)
            if key and len(key) >= 1:
                # Store position right after \r\r (headword start)
                entries.setdefault(key, []).append(after)
                last_entry_pos = p
                gap_since_last = 0
            else:
                gap_since_last += p - last_entry_pos

            pos = p + 2

        # Also track sorted entry start offsets for boundary detection
        self._webster_entry_offsets = sorted(offsets[0] for offsets in entries.values())
        self._entries = entries

    def _detect_names(self) -> bool:
        """Detect Names of God module (\\r\\r section headers, \\r entries).

        The Names module uses \\r\\r[A-Z]\\r\\r letter-section headers with
        individual name entries delimited by single \\r within each section.
        """
        abbrev = (self.abbrev or "").lower()
        if abbrev == "names":
            return True
        title = (self.title or "").lower()
        if "names of god" in title or "bible names" in title:
            return True
        fname = self.filepath.name.lower()
        if fname == "names":
            return True
        return False

    def _build_index_names(self):
        """Build index for Names module (concatenated entries, single-\\r delimited).

        The Names module uses \\r\\r[A-Z]\\r\\r as letter-section headers.
        Within each section, individual name entries are separated by
        single \\r characters.  Each entry has the format ``Name, definition.``
        """
        data = self.mm

        off = 0
        in_section = False

        while off < len(data) - 2:
            # Check for \\r\\r section header: \\r\\r[X]\\r\\r
            if data[off : off + 2] == b"\r\r":
                if off + 4 < len(data):
                    b0 = data[off + 2]
                    if 0x41 <= b0 <= 0x5A and data[off + 3 : off + 5] == b"\r\r":
                        # Section header found: skip it and enter section
                        in_section = True
                        off = off + 5  # right after \\r\\rX\\r\\r
                        continue
                # Otherwise just advance past \\r\\r
                off += 2
                continue

            if not in_section:
                off += 1
                continue

            # We're inside a section.  Find the end of the current entry
            # (next single \\r, or next \\r\\r section header).
            entry_start = off
            next_cr = data.find(b"\r", off)
            if next_cr < 0:
                next_cr = len(data)

            # If this \\r is part of a \\r\\r section header, exit section
            if next_cr + 2 < len(data) and data[next_cr : next_cr + 2] == b"\r\r":
                in_section = False
                off = next_cr
                continue

            entry_len = next_cr - entry_start
            if entry_len >= 2 and 0x41 <= data[entry_start] <= 0x5A:
                # Extract headword: everything before "," ";" " " or "."
                hw_end = entry_start
                for i in range(entry_start, min(next_cr, entry_start + 80)):
                    b = data[i]
                    if b in (0x2C, 0x3B, 0x3A, 0x20, 0x2E):
                        hw_end = i
                        break
                else:
                    hw_end = min(next_cr, entry_start + 80)

                hw_bytes = bytes(data[entry_start:hw_end])
                if len(hw_bytes) >= 2:
                    try:
                        hw_str = hw_bytes.decode("ascii")
                    except UnicodeDecodeError:
                        off = (
                            next_cr
                            + 1
                            + (
                                1
                                if next_cr + 1 < len(data) and data[next_cr + 1] == 0x0D
                                else 0
                            )
                        )
                        continue
                    # Quality filter: letters and hyphens only
                    if all(c.isalpha() or c in "-[" for c in hw_str):
                        key = hw_str.lower()
                        self._entries.setdefault(key, []).append(entry_start)
                        # Also index comma-separated alternate names
                        # e.g., "Cain, [Kain]" indexes both "cain" and "kain"
                        comma_parts = [
                            p.strip().strip("[] ") for p in hw_str.split(",")
                        ]
                        for alt in comma_parts[1:]:
                            if alt and len(alt) >= 2:
                                self._entries.setdefault(alt.lower(), []).append(
                                    entry_start
                                )

            # Advance to next entry
            off = (
                next_cr
                + 1
                + (1 if next_cr + 1 < len(data) and data[next_cr + 1] == 0x0D else 0)
            )

    def _detect_ni(self) -> bool:
        """Detect NI Bible Dictionary (single-\\r delimited English format).

        Checks the module abbreviation and filename for the NI Bible
        Dictionary module, which uses \\r + UPPERCASE as entry delimiters
        instead of the standard \\r\\r pattern.
        """
        abbrev = (self.abbrev or "").lower()
        if "ni bible" in abbrev:
            return True
        fname = self.filepath.name.lower()
        if "ni bible" in fname:
            return True
        parent = self.filepath.parent.name.lower()
        if "ni bible" in parent:
            return True
        return False

    def _detect_biblewords(self) -> bool:
        """Detect type-2 English dictionary modules that use binary word index
        + \\r\\r\\t body entry delimiters (Bible Words, Names, etc.).

        These modules have:
        1. A concatenated lowercase word index with digit prefix
        2. An offset table at 0x10000 (BE uint32 with hi16=0)
        3. Body text entries delimited by \\r\\r\\t

        Detection: type-2 module with a \\r\\r\\t count in body text that's
        significantly larger than the generic \\r\\r count captured by the
        standard English handler.
        """
        module_type = self._read_plist_module_type()
        if module_type != 2:
            return False

        data = self.mm
        # Scan body region (after 0x10000) for \r\r\t delimiters
        crcrtab = 0
        off = 0x10000
        delimiter = b"\r\r\t"
        while True:
            pos = data.find(delimiter, off)
            if pos < 0:
                break
            crcrtab += 1
            off = pos + 3
        # Must have a significant number of tab-delimited entries
        if crcrtab < 50:
            return False

        # Verify the offset table exists at 0x10000
        # Read first few values as BE uint32; they should be small and increasing
        import struct

        try:
            v0 = struct.unpack_from(">I", data, 0x10000)[0]
            v1 = struct.unpack_from(">I", data, 0x10004)[0]
            v2 = struct.unpack_from(">I", data, 0x10008)[0]
            # Values should be small (few thousand to tens of thousands)
            # and monotonically increasing
            if not (0 < v0 < 100000 and v0 < v1 < v2 and v1 - v0 < 200):
                return False
        except Exception:
            return False

        return True

    def _build_index_biblewords(self):
        """Build index for Bible Words / Names type-2 English dictionaries.

        These modules use a binary word index with a concatenated lowercase
        word block (no delimiters) and a cumulative offset table.  The
        offset table gives approximate word spans in the concatenated block,
        but the extracted spans are noisy due to missing delimiters.

        Instead of using the noisy concatenated spans as keys, we extract
        the headword from each body entry's first line.  Body entries are
        delimited by \\r\\r\\t.  The word index and body entries are in
        the same alphabetical order, so we match them sequentially,
        skipping front-matter entries.
        """
        import struct

        data = self.mm

        # --- 1. Read offset table at 0x10000 ---
        off_tab = 0x10000
        offset_vals = []
        for i in range(3000):
            v = struct.unpack_from(">H", data, off_tab + i * 4 + 2)[0]
            if v == 0 and i > 20:
                break
            offset_vals.append(v)

        valid_count = len(offset_vals)
        for i in range(1, len(offset_vals)):
            if offset_vals[i] <= offset_vals[i - 1] or offset_vals[i] == 0:
                valid_count = i
                break
        offset_vals = offset_vals[:valid_count]

        # --- 2. Find word block base ---
        wb_base = None
        for off in range(0x4500, 0x4800):
            if 0x61 <= data[off] <= 0x7A:
                lc_count = sum(
                    1
                    for j in range(off, min(off + 50, len(data)))
                    if 0x61 <= data[j] <= 0x7A
                )
                if lc_count >= 30:
                    wb_base = off
                    break
        if wb_base is None:
            wb_base = 0x4613

        # --- 3. Find body text entries ---
        body_entries = []
        off = 0x10000
        while True:
            pos = data.find(b"\r\r\t", off)
            if pos < 0:
                break
            body_start = pos + 3  # after \r\r\t
            body_entries.append(body_start)
            off = pos + 3

        # --- 4. Match words to body entries and extract headwords from body ---
        entries: dict[str, list[int]] = {}
        all_offsets = []
        body_idx = 0

        for i in range(len(offset_vals) - 1):
            if body_idx >= len(body_entries):
                break

            # Skip body entries that are front matter (preface, copyright, etc.)
            while body_idx < len(body_entries):
                bs = body_entries[body_idx]
                if bs + 10 > len(data):
                    body_idx += 1
                    continue
                peek = data[bs : min(bs + 80, len(data))]
                try:
                    first_line = peek.split(b"\r")[0]
                    first_text = first_line.decode("latin-1", errors="replace").strip()
                except Exception:
                    body_idx += 1
                    continue

                # Skip entries that look like front matter / internal content
                lower = first_text.lower()
                if (
                    lower.startswith("the bible is")
                    or lower.startswith("copyright")
                    or lower.startswith("preface")
                    or lower.startswith("introduction")
                    or lower.startswith("formerly")
                    or lower.startswith("this module")
                    or lower.startswith("all rights")
                ):
                    body_idx += 1
                    continue
                # Real entry found
                break

            if body_idx >= len(body_entries):
                break

            entry_offset = body_entries[body_idx]

            # Extract headword from body entry text
            bs = body_entries[body_idx]
            peek = data[bs : min(bs + 120, len(data))]
            try:
                first_line = peek.split(b"\r")[0]
                first_text = first_line.decode("latin-1", errors="replace").strip()
            except Exception:
                body_idx += 1
                continue

            # Parse headword: handle different entry formats
            key = self._extract_bw_headword(first_text)

            if key and len(key) >= 1:
                entries.setdefault(key, []).append(entry_offset)
                all_offsets.append(entry_offset)

            body_idx += 1

        self._entries = entries
        self._bw_body_starts = sorted(set(all_offsets))

    @staticmethod
    def _extract_bw_headword(text: str) -> str:
        """Extract the headword from a Bible Words body entry first line.

        Handles various entry formats:
          - "OT — Usually the word abandon, or forsake..." → "abandon"
          - "1. Abandon. The stronger..." → "abandon"
          - "NT — The NT references to abandonment..." → "abandonment"
          - "2. Forsake. Natas is the second..." → "forsake"
        """
        import re

        if not text or len(text) < 1:
            return ""

        # Strip Mac Roman smart-quote artifacts (0xD2-0xD5) from text
        # before parsing, since they're used as formatting, not part of words.
        text_clean = text
        for artifact, replacement in [
            ("\u00d2", "'"),  # Ò = left double quote (Mac Roman 0xD2)
            ("\u00d3", "'"),  # Ó = right double quote (Mac Roman 0xD3)
            ("\u00d4", "'"),  # Ô = left single quote (Mac Roman 0xD4)
            ("\u00d5", "'"),  # Õ = right single quote (Mac Roman 0xD5)
            ("\u00d1", "-"),  # Ñ = em dash (Mac Roman 0xD1)
            ("\u00ca", ""),  # Ê = formatting marker
        ]:
            text_clean = text_clean.replace(artifact, replacement)

        # Try "N. Headword. ..." pattern (numbered sub-entries)
        m = re.match(r"^(\d+)\.\s+(\w+)", text_clean)
        if m:
            hw = m.group(2).lower().rstrip(".:;,")
            # Strip non-ASCII artifacts
            hw = "".join(c for c in hw if c.isascii() and (c.isalpha() or c == "-"))
            if hw and len(hw) >= 2:
                return hw

        # Try "OT — Usually the word XXX" or "NT — ... word XXX" pattern
        m = re.search(
            r"(?:the\s+word|the\s+term|the\s+concept)\s+([\w\'-]+)", text_clean.lower()
        )
        if m:
            hw = m.group(1).lower().rstrip(".:;,")
            hw = "".join(c for c in hw if c.isascii() and (c.isalpha() or c == "-"))
            if hw and len(hw) >= 2:
                return hw

        # Try "— 1. Headword." pattern (ability entries)
        m = re.match(r"^(?:OT|NT|OT/NT)\s+[-\u2013\u2014]\s*\d+\.\s+(\w+)", text_clean)
        if m:
            hw = m.group(1).lower().rstrip(".:;,")
            hw = "".join(c for c in hw if c.isascii() and (c.isalpha() or c == "-"))
            if hw and len(hw) >= 2:
                return hw

        # Try "Headword — ..." pattern (name starts entry)
        m = re.match(r"^([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+[-\u2013\u2014]", text_clean)
        if m:
            hw = m.group(1).lower().rstrip(".:;,")
            hw = "".join(
                c for c in hw if c.isascii() and (c.isalpha() or c == "-" or c == " ")
            )
            if hw and len(hw) >= 2:
                return hw

        # Try first word as headword (many entries)
        first_word = text_clean.split()[0].lower().rstrip(".:;,")
        # Strip non-ASCII artifacts
        first_word = "".join(
            c for c in first_word if c.isascii() and (c.isalpha() or c == "-")
        )

        # Filter out common non-headword first words
        if first_word in (
            "ot",
            "nt",
            "the",
            "a",
            "an",
            "in",
            "of",
            "to",
            "for",
            "with",
            "from",
            "by",
            "and",
            "or",
            "not",
            "is",
            "are",
            "was",
            "were",
            "be",
            "been",
            "have",
            "has",
            "had",
            "this",
            "that",
            "these",
            "those",
            "it",
            "its",
            "we",
            "they",
            "he",
            "she",
            "his",
            "her",
            "our",
            "my",
            "your",
            "no",
            "yes",
            "all",
            "some",
            "any",
            "each",
            "every",
            "both",
            "few",
            "many",
            "more",
            "most",
            "much",
            "such",
            "only",
            "also",
            "then",
            "now",
            "thus",
            "therefore",
            "because",
            "since",
            "although",
            "though",
            "while",
            "when",
            "where",
            "what",
            "who",
            "whom",
            "whose",
            "which",
            "how",
            "why",
            "if",
            "than",
            "but",
            "so",
            "do",
            "does",
            "did",
            "may",
            "might",
            "must",
            "shall",
            "should",
            "will",
            "would",
            "can",
            "could",
        ):
            return ""
        if first_word and len(first_word) >= 2:
            return first_word

        return ""

    def _build_index(self):
        """Detect format and dispatch to the appropriate builder."""
        fmt = self._detect_format()
        self._format = fmt

        # Early detection: Bible Words / Names type-2 English dictionaries
        # with binary word index + \\r\\r\\t body entries.
        if self._detect_biblewords():
            self._is_biblewords = True
            self._is_english = True
            self._format = "biblewords"
            self._build_index_biblewords()
            return

        # Early detection: Webster 1913 uses a binary word index format,
        # not standard \\r\\r-delimited entries.  Check for the concatenated
        # word index signature before falling through to format detection.
        if self._detect_webster():
            self._is_webster = True
            self._is_english = True
            self._format = "webster"
            self._build_index_webster()
            return

        if fmt == "bdb":
            self._is_hebrew = True
            self._build_index_bdb()
        elif fmt == "km":
            self._is_hebrew = self.magic == 0x4A
            self._is_km = True
            self._is_numbered = True  # support number-based lookup
            self._build_index_km()
        elif fmt == "trench":
            self._is_hebrew = False
            self._build_index_trench()
        elif fmt == "numbered":
            self._is_numbered = True
            self._build_index_numbered_dual()
        elif fmt == "utf8":
            self._is_utf8 = True
            self._build_index_utf8()
        elif fmt == "tdot":
            if self._is_tdnt:
                self._is_hebrew = False
                self._is_tdot = False
                self._build_index_tdnt()
            else:
                self._is_hebrew = True
                self._is_tdot = True
                self._build_index_tdot()
        else:
            # 2-byte magic 0xFF2B → Hebrew/TDOT; but if TDNT (Greek), use standard
            if self.magic >= 0xFF00:
                if (self.magic & 0xFF) == 0x2B and not self._is_tdnt:
                    self._is_hebrew = True
                else:
                    self._is_hebrew = False
            else:
                module_type = self._read_plist_module_type()
                # Type-2 English dictionary modules (Easton, Nave's, Webster, etc.)
                if module_type == 2:
                    self._is_english = True
                    self._is_hebrew = False
                    # Names module uses \\r\\r section headers with
                    # \\r-delimited entries within each letter section.
                    if self._detect_names():
                        self._is_names = True
                        self._build_index_names()
                        return
                    # NI Bible Dictionary uses single-\\r delimiters
                    if self._detect_ni():
                        self._is_ni = True
                        self._build_index_ni()
                        return
                # Magic 0x28: ambiguous — Spicq (Greek, type 3),
                # Jenni-Westermann (Hebrew, type 4),
                # or Place Names (English, type 2, handled above)
                elif self.magic == 0x28:
                    if module_type == 4:
                        self._is_hebrew = True
                        self._is_tlot = True
                    else:
                        self._is_hebrew = False
                        self._is_spicq = True
                else:
                    self._is_hebrew = self.magic in (0x33, 0x40)
            self._build_index_standard()

    def _build_index_km(self):
        """Build index for KM Hebrew / Mounce Greek dictionary modules.

        These modules use a proprietary format with entries delimited by
            \\r\\rgk <LETTER><NUMBER> | s <STRONGS>\\xca\\xca<HW>\\xca\\xca<TRANS>\\xca\\xca<COUNT>x

        The binary file starts with a large binary index (~3 MB) followed
        by the text region.  Entry offsets are stored as the byte position
        right after ``\\r\\r`` (the start of ``gk ...``).
        """
        import re as _re2

        data = self.mm
        is_hebrew = self._is_hebrew

        # Entry header regex: \\r\\rgk <NUM> | s <STRONGS_REF>
        # The Strong's reference may be H####, G####, or [**]
        _gw_pat = (
            b"\r\rgk H(\\d+) \\| s (H\\d+|G\\d+|\\[\\*\\*\\])"
            if is_hebrew
            else b"\r\rgk G(\\d+) \\| s (G\\d+|H\\d+|\\[\\*\\*\\])"
        )

        off = 0x20000  # Skip binary index / header area
        while True:
            m = _re2.search(_gw_pat, data[off:])
            if m is None:
                break

            pos = off + m.start()
            gk_num = m.group(1).decode()
            strongs_ref = m.group(2).decode()

            # Entry offset: position right after \\r\\r (start of gk text)
            entry_offset = pos + 2

            # Headword bytes: between the first \\xca\\xca after | s ... and the next \\xca\\xca
            hdr_start = off + m.end()
            # Find \\xca\\xca after the Strong's reference
            sep1 = data.find(b"\xca\xca", hdr_start)
            if sep1 < 0 or sep1 - hdr_start > 40:
                off = pos + 4
                continue
            hw_start = sep1 + 2
            sep2 = data.find(b"\xca\xca", hw_start)
            if sep2 < 0 or sep2 - hw_start > 60:
                off = pos + 4
                continue
            hw_bytes = bytes(data[hw_start:sep2])

            if len(hw_bytes) < 1 or len(hw_bytes) > 60:
                off = pos + 4
                continue

            # Decode headword
            if is_hebrew:
                hw_decoded = decode_hebrew_always(hw_bytes)
                key = normalize_hebrew_headword(hw_decoded)
            else:
                hw_decoded = decode_always(hw_bytes)
                key = normalize_headword(hw_decoded)

            # Index by headword, GK number, and Strong's number
            if key and len(key) >= (1 if is_hebrew else 2):
                self._entries.setdefault(key, []).append(entry_offset)

            if gk_num:
                # Index by bare GK number for convenience (e.g., "4" finds H4)
                # NOTE: We deliberately do NOT index by "G" + gk_num or "H" + gk_num,
                # because GK numbers collide with Strong's numbers.  Both systems
                # use G/H prefixes but are completely different numbering schemes.
                # Strong's lookups must use the strongs_ref key (line below).
                self._entries.setdefault(gk_num, []).append(entry_offset)

            if strongs_ref and strongs_ref not in ("", "[**]"):
                self._entries.setdefault(strongs_ref, []).append(entry_offset)

            off = pos + 4

    def _build_index_numbered_dual(self):
        """Build numbered-format index, auto-detecting Greek vs Hebrew.

        For ambiguous magic bytes (0x33 Thayer/HALOT, 0x39 NAS Gr/Heb),
        builds with both encodings and selects the one that yields more
        distinct headword keys.
        """
        # 2-byte magics: 0xFF2B = TDOT/TDNT (Hebrew)
        if self.magic >= 0xFF00:
            self._is_hebrew = (self.magic & 0xFF) == 0x2B
            self._build_index_numbered()
            return

        # Known unambiguous cases
        if self.magic in (0x3B, 0x4A):
            self._is_hebrew = True
        elif self.magic in (0x2E, 0x3C):
            self._is_hebrew = False
        elif self.magic == 0x33:
            self._is_hebrew = False  # Thayer (Greek) — HALOT uses standard format
        elif self.magic == 0x39:
            # NAS Greek vs NAS Hebrew: both use same magic but different
            # languages. Detect from the module filename.
            fname_lower = self.filepath.name.lower()
            if "hebrew" in fname_lower:
                self._is_hebrew = True
            else:
                self._is_hebrew = False
            self._build_index_numbered()
            return

        self._build_index_numbered()

    def _build_index_bdb(self):
        """Build index for BDB Complete (magic 0x40, Strong's-numbered entries).

        BDB uses a distinct entry format:
            \\r\\r<number>[,\\s<number>...]\\t[\\xa0 ][[I. ][headword]]definition
        where <number> is a Strong's number (3-4 digits) and the headword
        is encoded in Accordance Hebrew encoding (same as HALOT).
        Some entries share multiple Strong's numbers (e.g. "120, 121").
        """
        data = self.mm
        off = 0x20000  # Skip header/index area

        _hebrew_byte = (
            HEBREW_CONSONANT_BYTES
            | _HEBREW_VOWEL_UPPER
            | _HEBREW_MARK_BYTES
            | _HEBREW_FORM_UPPER
        )

        while True:
            pos = data.find(b"\r\r", off)
            if pos < 0:
                break

            hw_raw_start = pos + 2
            if hw_raw_start >= len(data):
                break

            # Extract first entry number (Strong's number).
            # Handle comma-separated numbers like "120, 121" and
            # formats like "120, 121\t" where multiple Strong's numbers
            # point to the same entry.
            num_start = hw_raw_start
            while num_start < min(num_start + 6, len(data)):
                b = data[num_start]
                if 0x30 <= b <= 0x39:
                    num_start += 1
                    continue
                break
            num_len = num_start - hw_raw_start
            if num_len < 3 or num_len > 4:
                off = pos + 4
                continue

            entry_num = int(bytes(data[hw_raw_start:num_start]))

            # Skip any comma-separated additional numbers to find the tab
            tab_pos = num_start
            while tab_pos < min(num_start + 20, len(data)):
                b = data[tab_pos]
                if b == 0x09:  # tab found
                    break
                if b in (0x2C, 0x20, 0x3B):  # comma, space, semicolon
                    tab_pos += 1
                    continue
                if 0x30 <= b <= 0x39:  # additional digit (part of 2nd number)
                    tab_pos += 1
                    continue
                break
            else:
                off = pos + 4
                continue

            if tab_pos >= len(data) or data[tab_pos] != 0x09:
                off = pos + 4
                continue

            content_start = tab_pos + 1  # After tab

            if content_start >= len(data):
                off = pos + 4
                continue

            # Skip \xa0 paragraph indent
            hw_start = content_start
            if data[hw_start] == 0xA0:
                hw_start += 1
                if hw_start < len(data) and data[hw_start] == 0x20:
                    hw_start += 1  # skip space after NBSP

            # Skip brackets around reconstructed forms
            if hw_start < len(data) and data[hw_start] == 0x5B:  # '['
                hw_start += 1

            # Skip Roman numeral prefix (I., V.)
            if hw_start + 2 <= len(data) and data[hw_start : hw_start + 2] == b"I.":
                hw_start += 3
            elif hw_start + 2 <= len(data) and data[hw_start : hw_start + 2] == b"V.":
                hw_start += 3

            # Skip leading space
            while hw_start < len(data) and data[hw_start] == 0x20:
                hw_start += 1

            # Now at the headword start
            if hw_start >= len(data):
                off = pos + 4
                continue

            # For short link entries like "1\taDb v. II. abh.", the headword
            # may only be a few consonant bytes with no diacritics.
            # Allow all entries that start with a Hebrew consonant.
            first = data[hw_start]
            if first not in HEBREW_CONSONANT_BYTES:
                off = pos + 4
                continue

            # Find headword end: collect Hebrew-encoded bytes (consonants,
            # diacritics, vowel markers, form markers, internal separators)
            # until we hit English part-of-speech text or other non-Hebrew content.
            hw_end = hw_start
            for i in range(hw_start, min(hw_start + 80, len(data))):
                b = data[i]
                if b in _hebrew_byte or b in (0x3B, 0x2C, 0x5B, 0x5D):
                    hw_end = i + 1
                    continue
                if b == 0x20:
                    # Space: peek ahead. If the next word is a known
                    # English part-of-speech abbreviation or digit,
                    # the headword ends here.
                    peek_start = i + 1
                    while (
                        peek_start < min(i + 3, len(data)) and data[peek_start] == 0x20
                    ):
                        peek_start += 1
                    if peek_start >= len(data):
                        break
                    peek_b = data[peek_start]
                    # Digit after space = page/verse reference
                    if 0x30 <= peek_b <= 0x39:
                        break
                    # Check for common part-of-speech abbreviations:
                    # "n.", "vb.", "adj.", "pr.", "v.", "m.", "f."
                    if peek_b in (0x6E, 0x76, 0x61, 0x6D, 0x66):
                        # n, v, a, m, f — could start an abbreviation
                        if peek_start + 1 < len(data) and data[peek_start + 1] == 0x2E:
                            break  # Abbreviation like "n."
                        # Check for multi-char: "vb.", "pr.", "adj."
                        if peek_start + 2 < len(data) and data[peek_start + 2] == 0x2E:
                            peek_2 = data[peek_start + 1]
                            if (
                                (peek_b == 0x76 and peek_2 == 0x62)
                                or (peek_b == 0x70 and peek_2 == 0x72)
                                or (peek_b == 0x61 and peek_2 == 0x64)
                            ):
                                break  # "vb.", "pr.", "adj."
                    hw_end = i + 1
                    continue
                # Non-Hebrew byte ends the headword
                hw_end = i
                break

            hw_len = hw_end - hw_start
            if hw_len < 1 or hw_len > 60:
                off = pos + 4
                continue

            hw_bytes = bytes(data[hw_start:hw_end]).rstrip(b" ")

            if not _has_hebrew_pattern(hw_bytes):
                off = pos + 4
                continue

            hw_decoded = decode_hebrew_always(hw_bytes)
            key = normalize_hebrew_headword(hw_decoded)

            if key and len(key) >= 1:
                # Index by both Hebrew headword and Strong's number.
                # Store offset at content_start (after the entry-number tab)
                # so _read_entry returns the full entry including headword.
                self._entries.setdefault(key, []).append(content_start)
                self._entries.setdefault(f"H{entry_num}", []).append(content_start)
                self._entries.setdefault(f"H{entry_num:04d}", []).append(content_start)

            off = pos + 4

    def _build_index_numbered(self):
        """Build index for numbered-entry format: \\r\\r<number>. <headword>[; …]definition.

        Used by Greek Strong's (0x2E), Hebrew Strong's (0x3B), Thayer (0x33),
        NAS Greek (0x39), NAS Hebrew (0x39), Mounce Greek (0x3C),
        KM Hebrew (0x4A), Louw & Nida (0x44), and Spicq (0x28).
        """
        data = self.mm
        is_hebrew = self._is_hebrew
        off = 0x2000  # Skip header area

        while True:
            pos = data.find(b"\r\r", off)
            if pos < 0:
                break

            start = pos + 2
            if start >= len(data):
                break

            # Extract entry number: digits until '.' or end
            num_end = start
            while num_end < min(start + 8, len(data)):
                b = data[num_end]
                if 0x30 <= b <= 0x39:
                    num_end += 1
                    continue
                break

            num_len = num_end - start
            if num_len < 1:
                off = pos + 4
                continue

            if num_end >= len(data) or data[num_end] != 0x2E:  # '.'
                off = pos + 4
                continue

            # Louw & Nida format: XX.YY <headword>[, gender][; <headword2>[, gender2]]…: <definition>
            # The '.' is followed by more digits (subdomain number).
            if num_end + 1 < len(data) and 0x30 <= data[num_end + 1] <= 0x39:
                # Extract full XX.YY number
                sub_start = num_end + 1
                sub_end = sub_start
                while (
                    sub_end < min(sub_start + 5, len(data))
                    and 0x30 <= data[sub_end] <= 0x39
                ):
                    sub_end += 1
                entry_num_str = data[start:sub_end].decode("ascii", errors="replace")

                # Skip space after subdomain number
                hw_start = sub_end
                while hw_start < len(data) and data[hw_start] == 0x20:
                    hw_start += 1
                if hw_start >= len(data):
                    off = pos + 4
                    continue

                # Collect headwords from the headword region.
                # Headwords end at ',' (before gender), ';' (next headword),
                # or ':' (phrase entry, no gender).  Multi-headword entries
                # use ';' to separate individual headword+gender groups.
                # Some entries lack ':' and go straight from gender into
                # definition markers like 'Ñ ÔglossÕ'.
                headwords = []
                seg_start = hw_start
                for k in range(hw_start, min(hw_start + 120, len(data))):
                    b = data[k]
                    if b in (0x2C, 0x3B, 0x3A, 0x0D, 0x0A):
                        hw_bytes = bytes(data[seg_start:k]).strip()
                        if hw_bytes:
                            headwords.append(hw_bytes)
                        if b in (0x2C, 0x3A, 0x0D, 0x0A):
                            break  # gender info follows comma, or colon ends region
                        # ';' -> another headword follows after optional space
                        seg_start = k + 1
                        while seg_start < len(data) and data[seg_start] == 0x20:
                            seg_start += 1
                    elif b == 0x20:
                        # Space: could end the headword (transition to English
                        # definition) or separate words in a multi-word headword.
                        # Check the next ~15 bytes for genuine Greek encoding
                        # patterns vs Accordance formatting markers.
                        #
                        # Greek headwords use the pattern lowercase-highbyte-
                        # lowercase (consonant-diacritic-consonant), e.g.
                        # `e¦kkoyon` = 0x65,0xA6,0x6B,0x6B...
                        # Accordance formatting markers like Ñ Ô Õ
                        # (0xD1/0xD4/0xD5) don't have lowercase on both
                        # sides, so they won't match this pattern.
                        look = data[k + 1 : min(k + 16, len(data))]
                        has_greek_pattern = any(
                            0x61 <= look[i] <= 0x7A
                            and look[i + 1] >= 0x80
                            and 0x61 <= look[i + 2] <= 0x7A
                            for i in range(len(look) - 2)
                        )
                        has_other_markers = any(
                            b2 in (0x2F, 0x3A, 0x5C, 0xA7) for b2 in look
                        )
                        if not has_greek_pattern and not has_other_markers:
                            hw_bytes = bytes(data[seg_start:k]).strip()
                            if hw_bytes:
                                headwords.append(hw_bytes)
                            break
                else:
                    hw_bytes = bytes(
                        data[seg_start : min(hw_start + 120, len(data))]
                    ).strip()
                    if hw_bytes:
                        headwords.append(hw_bytes)

                for hw_bytes in headwords:
                    if not hw_bytes or any(b < 0x20 for b in hw_bytes):
                        continue
                    if len(hw_bytes) > 120:
                        continue
                    # Only reject purely-numeric headwords (e.g. entry number
                    # fragments).  Legacy filter rejected any headword
                    # containing a digit, but Louw & Nida has headwords
                    # like "paraÀ1" where the digit is legitimate.
                    if hw_bytes and all(
                        b == 0x20 or 0x30 <= b <= 0x39 for b in hw_bytes
                    ):
                        continue

                    hw_decoded = decode_always(hw_bytes)
                    key = normalize_headword(hw_decoded)
                    if key and len(key) >= 2:
                        # Must contain Greek encoding (high bytes, § for
                        # final sigma, or / or : for beta-code diacritics).
                        if any(b >= 0x80 or b in (0x2F, 0x3A, 0xA7) for b in hw_bytes):
                            self._entries.setdefault(key, []).append(start)
                    # Always store the number key, even for single-char headwords.
                    if entry_num_str:
                        self._entries.setdefault(entry_num_str, []).append(start)

                off = pos + 4
                continue

            # Skip '.' and following space(s)
            hw_start = num_end + 1
            while hw_start < len(data) and data[hw_start] == 0x20:
                hw_start += 1

            if hw_start >= len(data):
                off = pos + 4
                continue

            entry_num_str = data[start:num_end].decode("ascii", errors="replace")

            # Find headword end.
            # For numbered entries, the headword ends at:
            #   ';' (semicolon: most common), or
            #   first space followed by a Latin letter (start of transliteration)
            hw_end = hw_start
            for i in range(hw_start, min(hw_start + 80, len(data))):
                b = data[i]
                if b == 0x3B:  # semicolon ends headword
                    hw_end = i
                    break
                if b == 0x20:  # space: peek ahead for Latin letter
                    # Skip consecutive spaces
                    j = i + 1
                    while j < min(i + 4, len(data)) and data[j] == 0x20:
                        j += 1
                    if j < len(data):
                        peek = data[j]
                        # Lowercase Latin letter after space = transliteration
                        if peek in range(0x61, 0x7B):
                            hw_end = i
                            break
                    hw_end = i + 1
                    continue
                if b in (0x2C, 0x2E, 0x3A):  # comma, period, colon
                    hw_end = i
                    break
                if b < 0x20 or b == 0xFF:  # control char
                    break
                hw_end = i + 1

            hw_len = hw_end - hw_start
            if hw_len < 1 or hw_len > 60:
                off = pos + 4
                continue

            hw_bytes = bytes(data[hw_start:hw_end])

            if any(b < 0x20 for b in hw_bytes):
                off = pos + 4
                continue

            # Decode headword
            if is_hebrew:
                hw_decoded = decode_hebrew_always(hw_bytes)
                key = normalize_hebrew_headword(hw_decoded)
            else:
                hw_decoded = decode_always(hw_bytes)
                key = normalize_headword(hw_decoded)

            if key and len(key) >= (1 if is_hebrew else 2):
                self._entries.setdefault(key, []).append(start)
            # Always store the number key, even for single-char headwords.
            if entry_num_str:
                self._entries.setdefault(entry_num_str, []).append(start)

            off = pos + 4

    def _build_index_utf8(self):
        """Build index for UTF-8 Greek modules (EDG magic 0xFF20, MGS magic 0xFF2E).

        These use standard UTF-8 encoding for Greek headwords instead of
        the legacy Accordance 8-bit encoding.
        """
        data = self.mm
        off = 0x2000

        while True:
            pos = data.find(b"\r\r", off)
            if pos < 0:
                break

            start = pos + 2
            if start >= len(data):
                break

            b0 = data[start]

            # UTF-8 Greek: 2-byte sequences starting with 0xCE or 0xCF
            if b0 not in (0xCE, 0xCF):
                off = pos + 4
                continue

            # Decode UTF-8 headword until non-UTF-8-Greek byte
            hw_end = start
            for i in range(start, min(start + 120, len(data))):
                b = data[i]
                if b in (0xCE, 0xCF):
                    if i + 1 < len(data) and 0x80 <= data[i + 1] <= 0xBF:
                        hw_end = i + 2
                        continue
                if b == 0x20 or b == 0x09 or b == 0x0D:
                    break
                if b < 0x20 or b > 0x7E:
                    if b < 0x80:
                        break
                    hw_end = i + 1
                    continue
                hw_end = i + 1
                break

            hw_len = hw_end - start
            if hw_len < 2 or hw_len > 80:
                off = pos + 4
                continue

            try:
                hw_raw = bytes(data[start:hw_end]).decode("utf-8", errors="replace")
            except Exception:
                off = pos + 4
                continue

            # Only index if it contains Greek Unicode
            has_greek = any(
                "\u0370" <= c <= "\u03ff" or "\u1f00" <= c <= "\u1fff" for c in hw_raw
            )
            if not has_greek:
                off = pos + 4
                continue

            key = normalize_headword(hw_raw)
            if key and len(key) >= 2:
                self._entries.setdefault(key, []).append(start)

            off = pos + 4

    @staticmethod
    def _has_hebrew_markers(raw: bytes) -> bool:
        """True if decoded bytes contain actual Hebrew Unicode characters."""
        decoded = decode_hebrew_always(raw)
        return any("\u0590" <= c <= "\u05ff" for c in decoded)

    @staticmethod
    def _looks_like_beta_translit(raw: bytes) -> bool:
        """True if decoded text looks like beta-code transliteration, not Hebrew."""
        import re

        decoded = decode_hebrew_always(raw)
        # Pure ASCII lowercase = transliteration
        if decoded.isascii() and decoded.isalpha() and decoded.islower():
            return True
        # Short lowercase with no Hebrew glyphs = transliteration
        if not any("\u0590" <= c <= "\u05ff" for c in decoded):
            return True
        # Vowel clusters typical of transliteration
        if re.search(r"[aeiou]{2,}", decoded):
            return True
        return False

    @staticmethod
    def _extract_tdot_headwords(hw_bytes: bytes) -> list[bytes]:
        """Extract all Hebrew headwords from a TDOT header byte sequence.

        Brace-delimited headers: only the primary (first) headword.
        Non-brace headers: all words with Hebrew markers, filtering transliterations.
        Sub-entry separators (\\r\\r\\r) split compound headers.
        """

        # Split on sub-entry separators
        sub_entries = [hw_bytes]
        for sep in (b"\r\r\r", b"\r\r"):
            parts = hw_bytes.split(sep)
            if len(parts) > 1:
                sub_entries = [s.strip() for s in parts if s.strip()]
                break

        result = []
        for sub in sub_entries:
            if not sub:
                continue

            if b"}" in sub:
                # Brace-delimited: take only the PRIMARY headword
                # (first word before the first '}')
                first_seg = sub.split(b"}")[0].strip()
                if first_seg:
                    hw = first_seg.rstrip(b" *;,/")
                    if b" " in hw:
                        hw = hw.split(b" ")[0]
                    hw = hw.strip(b" *;,/")
                    if (
                        hw
                        and 0x61 <= hw[0] <= 0x7A
                        and LexiconModule._has_hebrew_markers(hw)
                        and not LexiconModule._looks_like_beta_translit(hw)
                    ):
                        result.append(hw)
            else:
                # Non-brace: extract words with Hebrew markers only
                for w in sub.split(b" "):
                    w = w.strip().rstrip(b" *;,/")
                    if not w or not (0x61 <= w[0] <= 0x7A):
                        continue
                    if LexiconModule._has_hebrew_markers(
                        w
                    ) and not LexiconModule._looks_like_beta_translit(w):
                        result.append(w)

        return result

    def _build_index_tdot(self):
        """Build index for TDOT (Theological Dictionary of the Old Testament).

        TDOT uses a custom format with magic 0xFF2B (not UTF-8):

        The module header at offset 0 contains a 0x100-byte record.
        Binary index at offsets ~0x2EB4–0x7E40 (Ghidra-validated, Session 24):
        - Offset table:  count * 5 bytes (uint32 values, 4-byte aligned)
        - Pool index:    count * 3 bytes
        - Term index:    additional data after pool
        Article bodies follow the index region.

        Articles are delimited by \\t\\xCA\\r\\xCA (headword/body separator).
        We scan for these delimiters directly and work backward to find the
        preceding \\r\\r article boundary, rather than scanning forward for
        \\r\\r patterns — the latter misses ~60% of articles because multiple
        \\r\\r patterns can precede a single delimiter (including overlapping
        \\r\\r\\r), and the "first \\r\\r found" is often not the article start.
        """
        data = self.mm
        TDOT_DELIM = b"\t\xca\r\xca"
        SNIPPET_SIZE = 300  # Some headwords are very long (60+ bytes with alternates)

        # Find all delimiter positions first.
        delim_positions = []
        scan = 0
        while True:
            dp = data.find(TDOT_DELIM, scan)
            if dp < 0:
                break
            delim_positions.append(dp)
            scan = dp + len(TDOT_DELIM)

        for dp in delim_positions:
            # Work backward from the delimiter to find the preceding \\r\\r.
            # Use rfind so we always get the closest \\r\\r (handles
            # overlapping \\r\\r\\r and multiple intervening \\r\\r patterns).
            search_start = max(0, dp - 400)
            rr_pos = data.rfind(b"\r\r", search_start, dp)
            if rr_pos < 0:
                continue

            pos = rr_pos
            after = pos + 2
            if after + SNIPPET_SIZE > len(data):
                continue

            b0 = data[after]
            if not (0x61 <= b0 <= 0x7A):
                continue

            snippet = data[after : min(after + SNIPPET_SIZE, len(data))]
            del_pos = snippet.find(TDOT_DELIM)
            if del_pos < 5:
                continue

            # Skip noise: entries where the \\r\\r is very close to the
            # delimiter (headword is just a few bytes — these are sub-
            # entries / index fragments, not real articles).
            if del_pos < 8:
                continue

            headword_bytes = bytes(snippet[:del_pos])
            heb_score = sum(
                1
                for b in headword_bytes
                if b in range(0x41, 0x5B)
                or b >= 0x80
                or b in (0x3B, 0x2C, 0x3A, 0x7B, 0x7D)
            )
            if heb_score < 1:
                continue

            # Verify body exists and is not purely binary.
            body_start = del_pos + len(TDOT_DELIM)
            body_end = min(body_start + 80, len(snippet))
            body_snippet = (
                snippet[body_start:body_end] if body_start < len(snippet) else b""
            )
            text_start = 0
            while text_start < len(body_snippet) and body_snippet[text_start] < 0x20:
                text_start += 1
            body_ascii = (
                sum(
                    1
                    for b in body_snippet[text_start : text_start + 20]
                    if 0x20 <= b <= 0x7E
                )
                if text_start < len(body_snippet)
                else 0
            )
            if body_ascii < 4:
                continue

            # Extract all Hebrew headwords from the header.
            headwords_raw = self._extract_tdot_headwords(headword_bytes)
            if not headwords_raw:
                continue

            for hw_bytes_raw in headwords_raw:
                try:
                    hw_decoded = decode_hebrew_always(hw_bytes_raw)
                except Exception:
                    continue
                key = normalize_hebrew_headword(hw_decoded)
                import re as _re

                key = _re.sub(r"\s+", "", key)
                if not key or len(key) < 1:
                    continue
                if len(key) > 10:
                    continue
                if any(c.isascii() and c.isalpha() for c in key):
                    continue
                self._entries.setdefault(key, []).append(pos)

        # Store article body boundaries for full-text search.
        # Body runs from just after one delimiter to just before the next,
        # or to the end of the file for the last article.
        for i, dp in enumerate(delim_positions):
            # Skip non-indexed articles (filtered out above).
            # Find the \\r\\r for this delimiter.
            search_start = max(0, dp - 400)
            rr_pos = data.rfind(b"\r\r", search_start, dp)
            if rr_pos < 0:
                continue

            body_start = dp + len(TDOT_DELIM)
            # Skip leading \\r\\r\\t formatting bytes in the body.
            ts = 0
            while body_start + ts < len(data) and data[body_start + ts] in (
                0x0D,
                0x09,
                0x20,
            ):
                ts += 1
            body_start += ts

            # End at the next delimiter's \\r\\r, or EOF.
            if i + 1 < len(delim_positions):
                next_dp = delim_positions[i + 1]
                search2 = max(0, next_dp - 400)
                art_end = data.rfind(b"\r\r", search2, next_dp)
                if art_end < 0:
                    art_end = body_start + 5_000_000
            else:
                art_end = len(data)

            self._tdot_article_bodies.append((body_start, art_end))

    def _search_tdot_body_for_key(self, search_key: str) -> Optional[str]:
        """Search TDOT article bodies for a term.

        For Hebrew terms: searches bodies for the raw Accordance Hebrew bytes
        (using indexed headwords as a source for raw bytes).
        For all terms: also searches the English body text for transliterations.

        Returns first match with context, or None.
        """
        if not self._tdot_article_bodies or len(search_key) < 2:
            return None

        TDOT_DELIM = b"\t\xca\r\xca"
        data = self.mm

        # Find indexed headwords containing the search key for raw bytes
        candidate_raw_terms = []
        for k, offsets in self._entries.items():
            if search_key in k and offsets:
                ref_off = offsets[0]
                after_rr = ref_off + 2
                if after_rr + 300 < len(data):
                    snippet = data[after_rr : after_rr + 300]
                    del_pos = snippet.find(TDOT_DELIM)
                    if del_pos >= 5:
                        hw_raw = bytes(snippet[:del_pos])
                        primary = hw_raw.split(b"}")[0].split(b" ")[0].rstrip(b" *;,/")
                        if len(primary) >= 2:
                            candidate_raw_terms.append(primary)

        # Search bodies for raw Accordance bytes or }-prefixed patterns
        for raw_term in candidate_raw_terms:
            for art_idx, (body_start, body_end) in enumerate(self._tdot_article_bodies):
                if body_end - body_start < len(raw_term):
                    continue
                pos = data.find(raw_term, body_start)
                if pos < 0 or pos >= body_end:
                    # Also try }-prefixed version
                    pos = data.find(b"}" + raw_term, body_start)
                if pos < 0 or pos >= body_end:
                    continue
                ctx_start = max(body_start, pos - 300)
                ctx_end = min(body_end, pos + 2000)
                ctx_raw = bytes(data[ctx_start:ctx_end])
                ctx_raw = bytes(b if b < 0x80 or b > 0x9F else 0x20 for b in ctx_raw)
                ctx_text = ctx_raw.decode("latin-1", errors="replace").replace(
                    "\r", " "
                )
                return f"[Cross-reference match in article]\n\n{ctx_text}"

        # Fallback: search English body text for search key or transliterations
        query_lower = search_key.lower()
        for art_idx, (body_start, body_end) in enumerate(self._tdot_article_bodies):
            if body_end - body_start < 10:
                continue
            body_raw = bytes(data[body_start : min(body_end, body_start + 200000)])
            body_raw = bytes(b if b < 0x80 or b > 0x9F else 0x20 for b in body_raw)
            body_text = body_raw.decode("latin-1", errors="replace")
            body_lower = body_text.lower()

            pos = body_lower.find(query_lower)
            if pos < 0:
                # Try consonant-only form of Hebrew text
                consonants_only = "".join(
                    c for c in search_key if "\u05d0" <= c <= "\u05ea"
                )
                if len(consonants_only) >= 2 and consonants_only != search_key:
                    pos = body_lower.find(consonants_only)
            if pos >= 0:
                ctx_start = max(0, pos - 200)
                ctx_end = min(len(body_text), pos + 1500)
                return f"[Full-text English match]\n\n{body_text[ctx_start:ctx_end]}"

        return None

    def _build_index_tdnt(self):
        """Build index for TDNT (Theological Dictionary of the New Testament).

        TDNT shares magic 0xFF2B with TDOT but uses a completely different
        entry format with two levels of article delimiters:

        * ``\\r\\r\\xA0`` — main article delimiter (1,139 articles).
        * ``\\r\\r\\r`` — sub-article delimiter within main articles
          (528 sub-articles, e.g. aÎgapáw/aÎgáph is a sub-article
          within the broader aîfilaÀgaqo§/aîgaqo/§ word-group article).

        Both patterns are followed by beta-code Greek headwords with
        Accordance 8-bit diacritics mixed in.  The headword ends at the
        next ``\\r\\r``; the body is English with inline Accordance Greek.

        The binary index occupies the first ~0x2900000 bytes; real entries
        begin after that.
        """
        import re as _re2
        import unicodedata as _ud

        data = self.mm
        off = 0x2900000  # Skip binary index region

        # Phase 1: Index main articles (\\r\\r\\xA0 delimiter)
        TDNT_ENTRY_START = b"\r\r\xa0"
        while True:
            pos = data.find(TDNT_ENTRY_START, off)
            if pos < 0:
                break
            self._index_tdnt_headers(pos, data, _re2, _ud)
            off = pos + 3

        # Phase 2: Index sub-articles (\\r\\r\\r delimiter)
        # These appear within main article bodies and cover individual words
        # that are sub-topics of the larger word-group articles.
        off = 0x2900000
        TDNT_SUB_START = b"\r\r\r"
        while True:
            pos = data.find(TDNT_SUB_START, off)
            if pos < 0:
                break

            hw_start = pos + 3
            if hw_start >= len(data):
                break

            # Validate: sub-article starts with lowercase a-z
            b0 = data[hw_start]
            if not (0x61 <= b0 <= 0x7A):
                off = pos + 3
                continue

            # Find the end of the headword
            hw_end = data.find(b"\r\r", hw_start)
            if hw_end < 0 or hw_end - hw_start > 300:
                off = pos + 3
                continue

            hw_bytes = bytes(data[hw_start:hw_end])

            # Skip entries that are clearly binary noise
            letter_count = sum(1 for b in hw_bytes if 0x41 <= b <= 0x7A)
            if letter_count < 2:
                off = pos + 3
                continue

            self._index_tdnt_headers(pos, data, _re2, _ud)
            off = pos + 3

        # Phase 3: Index cross-reference entries (plain \\r\\r delimiter).
        # These are short entries between main and sub articles, e.g.:
        #   \\r\\raîpisteÖw, aîpistiÖa, aàpisto§ pisteu/w\\r\\r[Vol. I, p. 386]
        # They appear in clusters and have very short bodies (volume refs).
        # Distinguish from internal section breaks by requiring Greek encoding
        # markers (8-bit diacritics or beta-code metacharacters).
        off = 0x2900000
        while True:
            pos = data.find(b"\r\r", off)
            if pos < 0:
                break

            hw_start = pos + 2
            if hw_start >= len(data):
                break

            b0 = data[hw_start]
            # Must start with lowercase Greek letter (a-z)
            if not (0x61 <= b0 <= 0x7A):
                off = pos + 2
                continue

            # Skip if this \r\r is already captured by Phase 1 or 2
            # (\\r\\r\\xA0 or \\r\\r\\r)
            if hw_start + 1 < len(data):
                if data[pos : pos + 3] == b"\r\r\xa0":
                    off = pos + 3
                    continue
                if data[pos : pos + 3] == b"\r\r\r":
                    off = pos + 3
                    continue

            # Find headword end (next \r\r within 300 bytes)
            hw_end = data.find(b"\r\r", hw_start)
            if hw_end < 0 or hw_end - hw_start > 300:
                off = pos + 2
                continue

            hw_bytes = bytes(data[hw_start:hw_end])

            # Must contain at least one Greek encoding marker:
            # 8-bit diacritic (0x80+) or beta-code metachar (/ \\ ^ :)
            has_greek_marker = any(
                b >= 0x80 or b in (0x2F, 0x5C, 0x5E, 0x3A) for b in hw_bytes
            )
            if not has_greek_marker:
                off = pos + 2
                continue

            # Must have at least 2 letters
            letter_count = sum(1 for b in hw_bytes if 0x41 <= b <= 0x7A)
            if letter_count < 2:
                off = pos + 2
                continue

            self._index_tdnt_headers(pos, data, _re2, _ud)
            off = pos + 2

    def _index_tdnt_headers(self, pos: int, data: bytes, _re2, _ud):
        """Extract headwords from a TDNT entry header and index them.

        Shared by both main-article and sub-article indexing.
        ``pos`` is the offset of the delimiter (``\\r\\r\\xA0`` or ``\\r\\r\\r``).
        """
        hw_start = pos + 3  # Skip past delimiter

        hw_end = data.find(b"\r\r", hw_start)
        if hw_end < 0 or hw_end - hw_start > 300:
            return

        hw_bytes = bytes(data[hw_start:hw_end])

        # Replace § markers with spaces instead of stripping them.
        # § (0xA7) encodes final sigma (ς) in Accordance Greek but is
        # also followed by section/page numbers or cross-reference
        # headwords (e.g. "aàpisto§ pisteu/w").  Replacing with space
        # preserves cross-ref tokens that would otherwise be lost.
        hw_bytes = hw_bytes.replace(b"\xa7", b" ")

        # Step 1: Split on \\t to separate main headword from cross-ref notes
        hw_main = hw_bytes.split(b"\t")[0].strip(b" \t\r\n\xa0")

        # Step 2: Strip parenthetical content from main headword
        hw_no_parens = _re2.sub(rb"\([^)]*\)", b" ", hw_main)
        hw_no_parens = hw_no_parens.strip(b" \t\r\n\xa0")

        # Step 3: Collect tokens from the main (non-paren) headword
        if hw_no_parens and len(hw_no_parens) >= 2:
            tokens = list(_re2.split(rb"[\s,;]+", hw_no_parens))
        else:
            tokens = []

        # Step 4: Extract clean tokens from short parentheticals
        for paren_match in _re2.finditer(rb"\(([^)]*)\)", hw_main):
            inner = paren_match.group(1).strip(b" \t\r\n\xa0")
            if not inner:
                continue
            inner_pieces = _re2.split(rb"[;\xf9]+", inner)
            for piece in inner_pieces:
                piece = piece.strip(b" \t\r\n\xa0")
                if not piece or len(piece) < 2:
                    continue
                # Skip pieces that look like page refs (mostly digits/abbrevs)
                letter_cnt = sum(1 for b in piece if 0x41 <= b <= 0x7A)
                digit_cnt = sum(1 for b in piece if 0x30 <= b <= 0x39)
                if digit_cnt > letter_cnt:
                    continue
                sub_tokens = _re2.split(rb"[\s,]+", piece)
                for st in sub_tokens:
                    st = st.strip(b" \t\r\n\xa0")
                    if st and len(st) >= 2:
                        tokens.append(st)

        for token in tokens:
            token = token.strip(b" \t\r\n\xa0")
            if not token or len(token) < 2:
                continue

            # Strip trailing digits (volume/page references) and punctuation
            token_clean = token.rstrip(b"0123456789.*()-;:!?")
            # Strip leading punctuation (parenthesis, dash, space, nbsp)
            token_clean = token_clean.lstrip(b" \t\r\n\xa0*-()")

            if len(token_clean) < 2:
                continue

            try:
                hw_decoded = decode_always(token_clean)
            except Exception:
                continue

            key = normalize_headword(hw_decoded)
            if not key:
                continue

            # Skip keys that are purely digits (noise from binary artifacts)
            if key.isdigit():
                continue

            # Skip keys that are pure Latin/English (no Greek characters)
            has_greek = any("GREEK" in _ud.name(c, "") for c in key if c.isalpha())
            if not has_greek and len(key) < 3:
                # Short Latin-only keys could be abbreviations; keep them
                pass
            elif not has_greek:
                continue

            if len(key) <= 1:
                continue

            # Store entry using the delimiter position
            self._entries.setdefault(key, []).append(pos)

    @staticmethod
    def _find_text_start(data: bytes) -> int:
        """Find the offset where lexicon text entries begin, skipping the binary header.

        Detects the transition from the sparse binary/index region to the dense
        text region by counting \\r\\r markers in 64 KB windows.  In the binary
        header, \\r\\r markers are extremely sparse (0-3 per 64 KB).  In the text
        region, they are dense (50-200+ per 64 KB, one per entry).

        When a window with >= 50 markers is found, the next window is verified
        to also be dense.  Returns the offset of the first dense window, or 0
        if no clear text region is found.
        """
        WINDOW = 0x10000  # 64 KB
        THRESHOLD = 15  # Minimum \\r\\r count per window for text region
        # (binary headers have 0-11, text regions have 13+)

        if len(data) < WINDOW * 2:
            return 0

        off = 0
        while off < len(data) - WINDOW:
            # Count \\r\\r markers in the current 64 KB window
            count = 0
            pos = off
            limit = off + WINDOW
            while pos < limit and pos < len(data):
                p = data.find(b"\r\r", pos)
                if p < 0 or p >= limit:
                    break
                count += 1
                pos = p + 2

            if count >= THRESHOLD:
                # Verify with the next 64 KB window (must also be dense)
                next_off = off + WINDOW
                if next_off + WINDOW <= len(data):
                    next_count = 0
                    pos = next_off
                    limit = next_off + WINDOW
                    while pos < limit and pos < len(data):
                        p = data.find(b"\r\r", pos)
                        if p < 0 or p >= limit:
                            break
                        next_count += 1
                        pos = p + 2

                    if next_count >= THRESHOLD:
                        # Confirmed: text region starts at this window
                        return off
                    # Both windows not dense — advance past this false positive
                    off = next_off
                    continue

                # Near EOF — single dense window is enough
                return off

            off += WINDOW

        return 0

    def _build_index_standard(self):
        """Build index for BDAG (type 3) and HALOT (type 4, magic 0x33).

        Also handles TLOT (Jenni-Westermann, magic 0x28 Hebrew type 4) and
        Spicq (magic 0x28 Greek type 3).
        """
        data = self.mm
        is_hebrew = self._is_hebrew
        is_tlot = self.magic == 0x28 and is_hebrew

        # TLOT uses overlapping \r\r\r patterns and needs smaller increments
        # to avoid skipping entries.  Standard modules can safely use +4.
        # TLOT also starts scanning at a higher offset to skip the binary
        # index and abbreviation front-matter (~0x65C000 in 14MB files).
        if is_tlot:
            off = 0x65C000
        else:
            off = self._find_text_start(data)
            # Fallback for sparse lexicons: if _find_text_start couldn't
            # find a dense window anywhere (returns 0), use 0x2000 as a
            # safe minimum that skips only the tiny file header.
            # A non-zero return always represents a valid text-region
            # start — even when deep in the file (e.g. 18 MB for ISBE's
            # large binary index).  Only the zero-sentinel triggers the
            # fallback; all other values are trusted.
            if off == 0:
                off = 0x2000

        def _advance(pos, valid=True):
            if is_tlot:
                if not valid and pos + 2 < len(data) and data[pos + 2] == 0x0D:
                    return pos + 1  # handle \r\r\r overlap
                return pos + 2
            return pos + 4 if valid else pos + 2

        marker = b"\r\r"
        while True:
            pos = data.find(marker, off)
            if pos < 0:
                break

            hw_raw_start = pos + 2

            if is_hebrew:
                # TLOT headwords start directly after \r\r with no prefix.
                # Using _skip_hebrew_prefix here would false-match Hebrew
                # consonants buried in body text after English prefixes.
                if is_tlot:
                    hw_start = hw_raw_start
                else:
                    hw_start = _skip_hebrew_prefix(data, hw_raw_start)
            else:
                hw_start = hw_raw_start

            first = data[hw_start] if hw_start < len(data) else 0

            if is_hebrew:
                valid_start = first in HEBREW_CONSONANT_BYTES
            elif self._is_english:
                valid_start = first in range(
                    0x41, 0x5B
                )  # uppercase A-Z for English (0x5A=Z)
            else:
                valid_start = first in range(0x61, 0x7B)

            if not valid_start:
                off = _advance(pos, valid=False)
                continue

            # Find headword end.
            # For Greek modules, \xa7 IS the final sigma (ς) and must be
            # included in the headword.  For Hebrew, \xa7 is never a Hebrew
            # character and correctly serves as a terminator/section marker.
            # \r (0x0D) is a terminator for both: Spicq uses \r\r as a line
            # separator, and TLOT uses \r\r to separate the headword line
            # from the entry body (the body starts after the \r\r that follows
            # the headword + transliteration + gloss).
            # Hebrew also uses } (0x7D) in TLOT to mark the transliteration
            # boundary, and tab (0x09) as a headword delimiter in some entries.
            hw_end = hw_start
            end_bytes = (
                (0x20, 0xA7, 0x2C, 0x3A, 0x7D, 0x09, 0x0D)
                if is_hebrew
                else (0x20, 0x2C, 0x0D)
            )
            for i in range(hw_start, min(hw_start + 80, len(data))):
                b = data[i]
                if b in end_bytes:
                    hw_end = i
                    break
            else:
                off = _advance(pos, valid=False)
                continue

            # TLOT front-matter guard: skip abbreviation entries.
            # The abbreviation/glossary section has English headers like
            # "OTHER JEWISH TEXTS" and "NEW TESTAMENT" whose uppercase ASCII
            # bytes overlap with the Hebrew encoding table, producing garbage
            # keys.  Filter them out by requiring at least one lowercase
            # consonant byte (0x61-0x7A) — real Hebrew headwords always
            # contain lowercase consonant bytes.
            hw_len = hw_end - hw_start
            min_len = 1 if is_hebrew else 3
            if hw_len < min_len or hw_len > 60:
                off = _advance(pos, valid=False)
                continue

            hw_bytes = bytes(data[hw_start:hw_end])

            if any(b in (0x20, 0x0D, 0x0A, 0x09, 0x00) for b in hw_bytes):
                off = _advance(pos, valid=False)
                continue
            if any(0x30 <= b <= 0x39 for b in hw_bytes):
                if not is_hebrew:
                    off = _advance(pos, valid=False)
                    continue

            # Content filter: real Hebrew headwords always contain lowercase
            # consonant bytes (0x61-0x7A).  Pure-uppercase ASCII words like
            # "OTHER", "NEW", "TEXTS" are English front-matter text that
            # accidentally decodes to Hebrew characters.
            if is_hebrew:
                has_hebrew_letter = any(b in range(0x61, 0x7B) for b in hw_bytes)
                if not has_hebrew_letter:
                    off = _advance(pos, valid=False)
                    continue

            if is_hebrew:
                # Single-letter Hebrew headwords: TLOT has entries for
                # individual letters (gimel, he, zayin, tet, yod, kaf, mem,
                # nun, samekh, ayin).  _has_hebrew_pattern() normally
                # requires 2+ consonants for unpointed entries.
                if (
                    is_tlot
                    and len(hw_bytes) == 1
                    and hw_bytes[0] in HEBREW_CONSONANT_BYTES
                ):
                    pass  # single-letter Hebrew entry — allowed
                elif not _has_hebrew_pattern(hw_bytes):
                    off = _advance(pos, valid=False)
                    continue
            elif self._is_english:
                # English headword quality: must be printable ASCII,
                # reject high bytes and control characters (binary noise).
                if any(b >= 0x80 or b < 0x20 for b in hw_bytes):
                    off = _advance(pos, valid=False)
                    continue
            elif not any(b >= 0x80 or b in (0x2F, 0x3A) for b in hw_bytes):
                off = _advance(pos, valid=False)
                continue

            if is_hebrew:
                hw_decoded = decode_hebrew_always(hw_bytes)
                key = normalize_hebrew_headword(hw_decoded)
            elif self._is_english:
                hw_decoded = hw_bytes.decode("latin-1", errors="replace")
                # Normalize: lowercase, strip trailing punctuation/whitespace
                key = hw_decoded.lower().rstrip(":;,. \t\r\n")
            else:
                hw_decoded = decode_always(hw_bytes)
                key = normalize_headword(hw_decoded)

            # Quality filter for Hebrew keys: reject headwords that contain
            # non-Hebrew characters.  English front-matter text produces
            # pseudo-Hebrew keys with mixed Greek letters (i/e/u decode to
            # ι/ε/υ) and ASCII punctuation.  Real Hebrew headwords contain
            # only Unicode Hebrew (\u0590-\u05FF), semicolons, backticks,
            # and underscores.
            if is_hebrew and key:
                valid_key = True
                for c in key:
                    if c == ";" or c == "`" or c == "_":
                        continue
                    if "\u0590" <= c <= "\u05ff":
                        continue
                    valid_key = False
                    break
                if not valid_key:
                    off = _advance(pos, valid=False)
                    continue

            if key and len(key) >= (1 if is_hebrew else (1 if self._is_english else 2)):
                if is_hebrew:
                    # Body quality check: reject entries with binary garbage
                    # in the first few bytes of the body text.  Real HALOT
                    # entries start with readable English/Hebrew; binary index
                    # noise shows null bytes and high control-character ratios.
                    body_start = hw_end + 1
                    body_check_len = min(80, len(data) - body_start)
                    if body_check_len > 0:
                        body_sample = data[body_start : body_start + body_check_len]
                        nulls = sum(1 for b in body_sample if b == 0x00)
                        ctrl_chars = sum(
                            1
                            for b in body_sample
                            if b < 0x20 and b not in (0x09, 0x0D, 0x0A)
                        )
                        if nulls > 0 or ctrl_chars > body_check_len // 2:
                            off = _advance(pos, valid=False)
                            continue

                    sec_pos = data.find(b"\xa7", hw_start, hw_start + 200)
                    if sec_pos >= 0:
                        self._entries.setdefault(key, []).append(sec_pos + 1)
                    else:
                        self._entries.setdefault(key, []).append(hw_end + 1)
                elif self._is_english:
                    # Body quality check: reject entries with binary garbage
                    # (null bytes, excessive control chars) from the index region.
                    body_start = hw_end + 1
                    body_check_len = min(80, len(data) - body_start)
                    if body_check_len > 0:
                        body_sample = data[body_start : body_start + body_check_len]
                        nulls = sum(1 for b in body_sample if b == 0x00)
                        ctrl_chars = sum(
                            1
                            for b in body_sample
                            if b < 0x20 and b not in (0x09, 0x0D, 0x0A)
                        )
                        if nulls > 0 or ctrl_chars > body_check_len // 2:
                            off = _advance(pos, valid=False)
                            continue

                    # Entry body starts after the headword terminator.
                    # Nave's-style: headword ends at \r, skip 1 byte to body
                    # (protects entry numbering like "1. A city...").
                    # Easton-style: headword ends at space/colon, body at hw_end.
                    if hw_end < len(data) and data[hw_end] == 0x0D:
                        entry_offset = hw_end + 1  # skip \r to body text
                    else:
                        entry_offset = hw_end
                    self._entries.setdefault(key, []).append(entry_offset)
                else:
                    # For Greek, the headword may include \xa7 (final sigma).
                    # Entry body starts after the headword terminator.
                    # When the terminator is \r (multi-line entries like Spicq,
                    # where \r\r separates the headword line from the body),
                    # skip past the \r\r delimiter to the body text.
                    if hw_end < len(data) and data[hw_end] == 0x0D:
                        entry_offset = hw_end + 2  # skip \r\r to body
                    elif self._is_spicq:
                        # Spicq entries always have \r\r after the headword
                        # line (even when the headword terminates at a
                        # comma or space with other Greek terms following).
                        # Find the \r\r that ends the headword line and
                        # skip past it so _read_entry starts at the gloss
                        # line, not inside the headword.
                        rr_after = data.find(b"\r\r", hw_end)
                        if rr_after >= 0 and rr_after - hw_end < 100:
                            entry_offset = rr_after + 2
                        else:
                            entry_offset = hw_end
                    else:
                        entry_offset = hw_end
                    # Spicq: always advance past the two-line header to body.
                    # Structure: \r\r<headword_line>\r\r<bib_line>\r\r<body>
                    # Bib lines can be 500+ bytes; header+[Vol.] can be 3 lines.
                    if self._is_spicq:
                        # Skip past first \r\r (end of headword/translit line)
                        rr1 = data.find(b"\r\r", entry_offset)
                        if rr1 != -1:
                            # Skip past second (bib line) and optional [Vol.] line
                            scan = rr1 + 2
                            for _ in range(3):
                                rr = data.find(b"\r\r", scan)
                                if rr == -1 or rr - scan > 5000:
                                    break
                                after = data[rr + 2 : rr + 40]
                                # Still header if starts with [ or is a Strong's ref
                                if after[:1] == b"[" or (
                                    len(after) >= 2
                                    and after[0:1].isdigit()
                                    and after[1:2] == b"."
                                ):
                                    scan = rr + 2
                                    continue
                                # This is body text
                                entry_offset = rr + 2
                                break
                    self._entries.setdefault(key, []).append(entry_offset)

            off = _advance(pos, valid=True)

    def _build_index_ni(self):
        """Build index for NI Bible Dictionary (single-\\r delimited entries).

        This module uses \\r + UPPERCASE HEADWORD as the entry delimiter,
        unlike the standard \\r\\r pattern.  Sub-entries use \\r\\r and are
        skipped during indexing (their content is part of the parent entry).
        """
        data = self.mm

        off = self._find_text_start(data)
        # Fallback for sparse lexicons: see _build_index_standard above.
        if off == 0 or off > 0x100000:
            off = 0x2000

        while True:
            pos = data.find(b"\r", off)
            if pos < 0:
                break

            if pos + 3 >= len(data):
                break

            # Skip \\r\\r (sub-entry markers) — advance past both \\r bytes
            if data[pos + 1] == 0x0D:
                off = pos + 2
                continue

            # Must be followed by 2+ uppercase ASCII letters (A-Z)
            b0 = data[pos + 1]
            b1 = data[pos + 2]
            if not (0x41 <= b0 <= 0x5A and 0x41 <= b1 <= 0x5A):
                off = pos + 1
                continue

            # Extract headword: uppercase letters, spaces, and commas.
            # Multi-word headwords like "MOST HIGH", "JESUS CHRIST", and
            # "MUSIC AND MUSICAL INSTRUMENTS" have internal spaces.
            # Terminators: '.' (period), '(' (open paren),
            # space before '(' or lowercase, or CR.
            hw_start = pos + 1
            hw_end = hw_start
            for i in range(hw_start, min(hw_start + 80, len(data))):
                b = data[i]
                if b == 0x0D:  # CR -> end of headword
                    hw_end = i
                    break
                if b == 0x2E:  # period -> end, exclude it from headword
                    hw_end = i
                    break
                if b == 0x28:  # '(' -> headword ends before paren
                    hw_end = i
                    break
                if b == 0x20:  # space -> check what follows
                    j = i + 1
                    while j < min(i + 3, len(data)) and data[j] == 0x20:
                        j += 1
                    if j < len(data):
                        peek = data[j]
                        if peek == 0x28 or 0x61 <= peek <= 0x7A:
                            # '(' or lowercase after space -> headword ends
                            hw_end = i
                            break
                    # Space inside multi-word headword (followed by uppercase)
                    hw_end = i + 1
                    continue
                if 0x41 <= b <= 0x5A or b == 0x2C:
                    hw_end = i + 1
                    continue
                # Unknown byte -> end headword
                hw_end = i
                break
            else:
                off = pos + 1
                continue

            hw_len = hw_end - hw_start
            if hw_len < 2 or hw_len > 50:
                off = pos + 1
                continue

            hw_bytes = bytes(data[hw_start:hw_end])

            # Quality check: only uppercase ASCII letters, spaces, and commas
            # (reject binary noise with high bytes or control chars)
            if not all(b in range(0x41, 0x5B) or b in (0x20, 0x2C) for b in hw_bytes):
                off = pos + 1
                continue
            # Must contain at least 2 uppercase letters
            if sum(1 for b in hw_bytes if 0x41 <= b <= 0x5A) < 2:
                off = pos + 1
                continue

            # Strip trailing punctuation and spaces from multi-word headwords
            # e.g., "MOST HIGH." -> "MOST HIGH", "MOLECH, " -> "MOLECH"
            hw_str = hw_bytes.decode("ascii").rstrip(".,;: ")
            key = hw_str.lower()

            # Store offset at the \\r position (start of entry marker).
            # Before storing, validate the entry body is real English text,
            # not binary noise (spurious entries like 'ad', 'ae', 'zzg' can
            # pass the uppercase check because random bytes look like A-Z).
            body_start = hw_end + 1
            body_sample = bytes(data[body_start : min(body_start + 200, len(data))])
            body_alpha = sum(
                1 for b in body_sample if 0x41 <= b <= 0x5A or 0x61 <= b <= 0x7A
            )
            body_spaces = sum(1 for b in body_sample if b == 0x20)
            if body_alpha < 20 or body_spaces < 3:
                off = pos + 1
                continue

            self._entries.setdefault(key, []).append(pos)

            # Also index comma-separated alternate names
            # e.g., "MOLECH, MOLOCH" indexes both "molech" and "moloch"
            parts = [p.strip() for p in hw_str.split(",")]
            for alt in parts[1:]:
                if alt and len(alt) >= 2:
                    self._entries.setdefault(alt.lower(), []).append(pos)

            off = pos + 1

    def _build_index_trench(self):
        """Build index for Trench's Synonyms of the New Testament (magic 0x1D).

        Sections are delimited by \\r\\r\\r¤ (three CR + section symbol 0xA4).
        Each section has a Roman numeral, then comma-separated Greek headwords
        in Accordance 8-bit encoding, ending with a period before \\r\\r.
        Entry offset is stored right after the \\r\\r\\r¤ marker.
        """
        import re as _re3

        data = self.mm
        marker = b"\r\r\r\xa4"

        off = 0
        while True:
            p = data.find(marker, off)
            if p < 0:
                break

            entry_offset = p + 4  # right after \r\r\r¤

            # Skip space, Roman numeral, period, and space(s)
            first_dot = data.find(b".", entry_offset, entry_offset + 20)
            if first_dot < 0:
                off = p + 4
                continue

            hw_pos = first_dot + 1
            while hw_pos < len(data) and data[hw_pos] == 0x20:
                hw_pos += 1

            # Find end of headword list: the period before \r\r
            rr = data.find(b"\r\r", hw_pos)
            if rr < 0 or rr <= hw_pos:
                off = p + 4
                continue

            # Headword list ends at the '.' preceding \r\r
            if data[rr - 1] == 0x2E:
                hw_end = rr - 1
            else:
                hw_end = min(hw_pos + 100, len(data))

            if hw_end <= hw_pos:
                off = p + 4
                continue

            hw_raw = bytes(data[hw_pos:hw_end])
            hw_text = decode_always(hw_raw, trench=True)

            # Strip parenthesized cross-references from the headword line
            # before splitting, because a comma inside parens is NOT a
            # headword separator.  E.g. "ἔλαιον, μύρον (χρίω, ἀλείφω)"
            # has two headwords: "ἔλαιον" and "μύρον".
            hw_clean = _re3.sub(r"\([^)]*\)", "", hw_text)

            for part in hw_clean.split(","):
                part = part.strip().rstrip(".")
                if not part or len(part) < 2:
                    continue
                key = normalize_headword(part)
                if key:
                    self._entries.setdefault(key, []).append(entry_offset)

            off = p + 4

    def _get_first_offset(self, key: str) -> Optional[int]:
        """Get the first stored offset for a key, or None."""
        offsets = self._entries.get(key)
        if offsets:
            return offsets[0]
        return None

    def _read_entries_by_key(self, key: str) -> Optional[str]:
        """Read all entries for a key, joining homographs with a separator."""
        offsets = self._entries.get(key, [])
        if not offsets:
            return None
        if len(offsets) == 1:
            return self._read_entry(offsets[0])
        entries = [self._read_entry(off) for off in offsets]
        return "\n\n---\n\n".join(entries)

    def _build_citation_header(self, headword: str) -> str:
        """Return a citation header string for use by lookup/preview/metadata methods."""
        from .citation_format import lexicon_citation_markdown

        return lexicon_citation_markdown(headword, self.abbrev, self.title)

    def _resolve_entry_key(self, headword: str) -> tuple[str, list[int]]:
        """Return (effective_key, offsets) using the same resolution logic as lookup().

        BDB modules expand bare digits and H-prefixed numbers to H{nnn} / H{nnnn}
        formats.  Numbered modules try the headword as-is for entry-number lookup.
        Falls through to standard headword normalization (Hebrew/Greek/English)
        with final-sigma stripping for Greek.
        """
        # BDB number conversion: try all Strong's number formats
        if self._is_bdb:
            for candidate in [headword] + (
                [f"H{int(headword[1:])}", f"H{int(headword[1:]):04d}"]
                if headword.startswith("H") and headword[1:].isdigit()
                else [f"H{int(headword)}", f"H{int(headword):04d}"]
                if headword.isdigit()
                else []
            ):
                offsets = self._entries.get(candidate)
                if offsets:
                    return candidate, offsets

        # Headword normalization
        if self._is_hebrew:
            key = normalize_hebrew_headword(headword)
        elif self._is_english:
            key = headword.lower().rstrip(":;,. \t\r\n")
        else:
            key = normalize_headword(headword)

        offsets = self._entries.get(key, [])

        # Final sigma strip for Greek
        if not offsets and not self._is_hebrew and not self._is_english:
            key2 = key.rstrip("\u03c2").rstrip("\u03c3")
            if key2 != key:
                offsets = self._entries.get(key2, [])
                if offsets:
                    key = key2

        return key, offsets

    def lookup(
        self, headword: str, citation: bool = False, summary_level: str = "full"
    ) -> Optional[str]:
        """Look up a headword and return its definition text.

        For Greek modules (BDAG), provide Unicode Greek (e.g., 'λόγος').
        For Hebrew modules (HALOT), provide Unicode Hebrew (e.g., 'אָב').
        For numbered-entry modules, also supports entry numbers (e.g., '975').
        For BDB Complete, also supports Strong's numbers (e.g., 'H430', '430').

        When multiple entries share the same normalized key (homographs),
        all matching entries are returned joined with a markdown separator.

        Args:
            headword: The headword to look up.
            citation: If True, prepend a citation header (module name, etc.).
            summary_level: Truncation level for LLM context-window efficiency.
                'full' (default): no truncation.
                'standard': truncate to ~500 chars at word boundary.
                'brief': truncate to ~150 chars at word boundary.

        Returns the definition text as a string, or None if not found.
        """
        cite = (
            (lambda r: self._build_citation_header(headword) + r)
            if citation
            else (lambda r: r)
        )

        # BDB Strong's number lookup
        if self._is_bdb:
            result = self._read_entries_by_key(headword)
            if result is not None:
                return _truncate_body(cite(result), _SUMMARY_LIMITS.get(summary_level))
            if headword.startswith("H"):
                try:
                    num = int(headword[1:])
                    for fmt in (f"H{num}", f"H{num:04d}"):
                        result = self._read_entries_by_key(fmt)
                        if result is not None:
                            return _truncate_body(
                                cite(result), _SUMMARY_LIMITS.get(summary_level)
                            )
                except ValueError:
                    pass
            else:
                try:
                    num = int(headword)
                    for fmt in (f"H{num}", f"H{num:04d}"):
                        result = self._read_entries_by_key(fmt)
                        if result is not None:
                            return _truncate_body(
                                cite(result), _SUMMARY_LIMITS.get(summary_level)
                            )
                except ValueError:
                    pass

        # Numbered entry modules: try number lookup first
        if self._is_numbered:
            result = self._read_entries_by_key(headword)
            if result is not None:
                return _truncate_body(cite(result), _SUMMARY_LIMITS.get(summary_level))

        # Headword lookup
        if self._is_hebrew:
            key = normalize_hebrew_headword(headword)
        elif self._is_english:
            key = headword.lower().rstrip(":;,. \t\r\n")
        else:
            key = normalize_headword(headword)

        # Exact match
        result = self._read_entries_by_key(key)
        if result is not None:
            return _truncate_body(cite(result), _SUMMARY_LIMITS.get(summary_level))

        # Try stripping final sigma (Greek only)
        if not self._is_hebrew and not self._is_english:
            key2 = key.rstrip("\u03c2").rstrip("\u03c3")
            result = self._read_entries_by_key(key2)
            if result is not None:
                return _truncate_body(cite(result), _SUMMARY_LIMITS.get(summary_level))

        # Prefix match: headword starts with query
        if self._is_hebrew:
            search_key = key
        elif self._is_english:
            search_key = key
        else:
            search_key = key.rstrip("\u03c2").rstrip("\u03c3")
        if search_key:  # skip prefix match for empty query
            for k, offsets in self._entries.items():
                if k.startswith(search_key):
                    if len(offsets) > 1:
                        entries = [self._read_entry(off) for off in offsets]
                        return _truncate_body(
                            cite("\n\n---\n\n".join(entries)),
                            _SUMMARY_LIMITS.get(summary_level),
                        )
                    return _truncate_body(
                        cite(self._read_entry(offsets[0])),
                        _SUMMARY_LIMITS.get(summary_level),
                    )

        # TDOT full-text fallback: search article bodies for unmatched terms
        if self._is_tdot and self._is_hebrew and len(search_key) >= 2:
            result = self._search_tdot_body_for_key(search_key)
            if result:
                return _truncate_body(cite(result), _SUMMARY_LIMITS.get(summary_level))

        return None

    def _decode_numbered_greek_entry(self, raw: bytes) -> str:
        """Decode a numbered-format Greek entry with beta-code headword.

        Parses the entry into prefix (number), headword (beta-code Greek),
        and suffix (definition).  The headword is decoded with decode_always()
        so pure beta-code (no 8-bit diacritics) still becomes Unicode Greek.
        """
        n = len(raw)
        i = 0

        # Skip number prefix: digits, '.', and spaces
        while i < n and (0x30 <= raw[i] <= 0x39 or raw[i] in (0x2E, 0x20)):
            i += 1

        hw_start = i
        hw_end = hw_start
        for j in range(hw_start, min(hw_start + 80, n)):
            b = raw[j]
            if b == 0xA7:  # \u00a7 marks end of headword in numbered Greek modules
                hw_end = j
                break
            if b in (0x3B, 0x20):  # ';' or space
                # Peek ahead: if followed by lowercase Latin, headword ends here
                k = j + 1
                while k < min(j + 4, n) and raw[k] == 0x20:
                    k += 1
                if k < n and raw[k] in range(0x61, 0x7B):
                    hw_end = j
                    break
                hw_end = j + 1
                continue
            if b < 0x20:
                break
            hw_end = j + 1

        if hw_end <= hw_start:
            return decode(raw)

        prefix = raw[:hw_start].decode("ascii", errors="replace")
        hw_decoded = decode_always(bytes(raw[hw_start:hw_end]))
        suffix = decode(raw[hw_end:])
        return f"{prefix}**{hw_decoded}**{suffix}"

    def _read_entry(self, offset: int) -> str:
        """Read a full entry starting from the stored offset.

        The entry ends at the next \r\r pattern or end of file.
        For Hebrew modules, decodes any Accordance Hebrew encoding in the
        definition text so headwords appear as Unicode Hebrew.
        For Greek-encoded modules (BDAG, numbered Greek), uses Accordance
        Greek decoding.
        For UTF-8 modules, returns UTF-8 decoded text.
        For TDOT, returns Latin-1 body text (English with Hebrew headword
        already stripped).
        For TDNT, reads from the \r\r\xa0 entry marker to the next entry,
        stripping the headword and decoding the body as Accordance Greek+English.
        """
        data = self.mm
        raw_offset = offset

        if self._is_biblewords:
            # offset points to the start of body text after \r\r\t.
            # Entry ends at the next \r\r\t delimiter or EOF.
            end = data.find(b"\r\r\t", raw_offset + 4)
            if end < 0:
                # No next delimiter: cap at 2000 bytes from entry start.
                # Bible Words entries are short (≤500 bytes); anything
                # longer means we've overshot into the binary tail.
                end = min(raw_offset + 2000, len(data))
            raw = bytes(data[raw_offset:end])
            # Filter C1 control bytes and decode as Latin-1
            raw = bytes(b if b < 0x80 or b > 0x9F else 0x20 for b in raw)
            text = raw.decode("latin-1", errors="replace").replace("\r", "\n").strip()
            return _postprocess_decoded(text)

        if self._is_names:
            # Names entries are \\r-delimited within \\r\\r letter sections.
            # Find the end of this entry: next \\r that isn't part of \\r\\r.
            end = data.find(b"\r", raw_offset + 2)
            if end < 0:
                end = data.find(b"\r\r", raw_offset + 2)
                if end < 0:
                    end = len(data)
            raw = bytes(data[raw_offset:end])
            raw = bytes(b if b < 0x80 or b > 0x9F else 0x20 for b in raw)
            text = raw.decode("latin-1", errors="replace").replace("\r", "").strip()
            return _postprocess_decoded(text)

        if self._is_tdnt:
            # offset points to one of three delimiter types:
            #   \r\r\xA0  = main article delimiter
            #   \r\r\r   = sub-article delimiter
            #   \r\r     = cross-reference entry (plain)
            # All have the same headword+body structure.
            b2 = data[offset + 2] if offset + 2 < len(data) else 0
            if b2 == 0xA0:
                skip = 3  # \r\r\xA0
            elif b2 == 0x0D:
                skip = 3  # \r\r\r
            else:
                skip = 2  # plain \r\r

            hw_start = offset + skip

            # Find headword end (next \r\r within reasonable range)
            hw_end = data.find(b"\r\r", hw_start)
            if hw_end < 0 or hw_end - hw_start > 300:
                hw_end = hw_start  # fallback: skip no headword bytes
            body_start = hw_end + 2  # skip past \r\r to body

            if skip == 3 and b2 == 0xA0:
                # Main article: end at next \r\r\xA0 or EOF
                end = data.find(b"\r\r\xa0", body_start)
                if end < 0:
                    end = len(data)
            elif skip == 3:
                # Sub-article (\r\r\r): end at next \r\r\r or \r\r\xA0
                next_rrr = data.find(b"\r\r\r", body_start)
                next_a0 = data.find(b"\r\r\xa0", body_start)
                candidates = []
                if next_rrr >= 0:
                    candidates.append(next_rrr)
                if next_a0 >= 0:
                    candidates.append(next_a0)
                end = min(candidates) if candidates else len(data)
            else:
                # Cross-reference entry (plain \r\r): short body,
                # end at next \r\r, \r\r\r, or \r\r\xA0 (whichever first)
                next_rr = data.find(b"\r\r", body_start)
                next_rrr = data.find(b"\r\r\r", body_start)
                next_a0 = data.find(b"\r\r\xa0", body_start)
                candidates = []
                if next_rr >= 0:
                    candidates.append(next_rr)
                if next_rrr >= 0:
                    candidates.append(next_rrr)
                if next_a0 >= 0:
                    candidates.append(next_a0)
                end = min(candidates) if candidates else len(data)

            raw = bytes(data[body_start:end])
            return decode(raw).replace("\r", "\n").strip()

        if self._format == "trench":
            # offset points right after \r\r\r¤ marker.
            # Read through to the next \r\r\r¤ entry boundary.
            end = data.find(b"\r\r\r\xa4", raw_offset + 4)
            if end < 0:
                end = len(data)
            raw = bytes(data[raw_offset:end])
            # Universal binary tail guard: truncate at the first double-null
            # region. Valid entry text never contains consecutive null bytes.
            null_pos = raw.find(b"\x00\x00")
            if null_pos >= 0:
                raw = raw[:null_pos]
            return decode(raw, trench=True).replace("\r", "\n").strip()

        if self._is_tdot:
            # Skip past the headword+delimiter to the article body
            TDOT_DELIM = b"\t\xca\r\xca"
            after_rr = offset + 2
            snippet = data[after_rr : min(after_rr + 300, len(data))]
            del_pos = snippet.find(TDOT_DELIM)
            if del_pos >= 0:
                raw_offset = after_rr + del_pos + len(TDOT_DELIM)
            # Find the end of this article: next \r\r that starts with
            # a Hebrew-pattern headword (lowercase consonant + Hebrew markers).
            # Skip \r\r\t outline markers and other internal \r\r.
            # Safety limit: if we scan more than 1MB without finding the next
            # article, stop — the article likely extends into the binary tail.
            MAX_SCAN = 1_000_000
            end = raw_offset
            scan_start = raw_offset
            while True:
                next_rr = data.find(b"\r\r", end + 1)
                if next_rr < 0 or next_rr - scan_start > MAX_SCAN:
                    end = min(end + MAX_SCAN, len(data))
                    break
                # Check if this \r\r starts a new article
                after = next_rr + 2
                if after + 3 > len(data):
                    end = len(data)
                    break
                b0 = data[after]
                if not (0x61 <= b0 <= 0x7A):
                    end = next_rr
                    continue
                # Check for Hebrew markers in the next 30 bytes
                look = data[after : min(after + 30, len(data))]
                heb = sum(
                    1
                    for b in look
                    if b in range(0x41, 0x5B) or b >= 0x80 or b in (0x3B, 0x2C)
                )
                if heb >= 2:
                    end = next_rr
                    break
                end = next_rr
        else:
            if self._is_km:
                # KM entries end at the next \\r\\rgk entry delimiter,
                # not at plain \\r\\r (which appears within entries).
                km_prefix = b"\r\rgk H" if self._is_hebrew else b"\r\rgk G"
                end = data.find(km_prefix, raw_offset + 10)
                if end < 0:
                    end = len(data)
            else:
                if self._is_tlot:
                    # TLOT (Jenni-Westermann) uses \r\r as internal
                    # section dividers within entries (e.g. \r\r\tS,
                    # \r\r\tI.) and \r\r\r as a sub-article delimiter.
                    # Find the true next-entry boundary:
                    # \r\r followed by a Hebrew headword (lowercase
                    # consonant + Hebrew markers or } within 40 bytes),
                    # NOT followed by \t (internal divider).
                    # Also: \r\r\r (three \r) is a sub-article delimiter
                    # like TDNT; the byte after the third \r starts the
                    # headword.
                    end = raw_offset
                    while True:
                        # Total-bytes-scanned budget: if we've progressed
                        # more than 500 KB from the entry start, we've
                        # likely overshot into the binary tail.  No
                        # legitimate TLOT entry exceeds 500 KB.
                        if end - raw_offset > 500_000:
                            break
                        next_rr = data.find(b"\r\r", end)
                        if next_rr < 0 or next_rr - raw_offset > 500_000:
                            break
                        # Binary tail guard: if the gap since the last
                        # checked position is large and full of nulls,
                        # we've overshot into the binary tail.  Stop here.
                        gap = next_rr - end
                        if gap > 50000:
                            sample = data[end : min(end + 200, len(data))]
                            nulls = sum(1 for b in sample if b == 0)
                            printable = sum(1 for b in sample if 0x20 <= b <= 0x7E)
                            if nulls > len(sample) // 2 or (
                                len(sample) > 0 and printable < len(sample) // 20
                            ):
                                break  # use current end — don't advance into tail
                        # Double-null guard: consecutive null bytes never appear
                        # in valid entry text.  If the gap contains \x00\x00,
                        # we've entered the binary tail region.  Stop at the
                        # first double-null boundary.
                        if gap > 0:
                            null2_pos = data.find(b"\x00\x00", end, next_rr)
                            if null2_pos >= 0:
                                end = null2_pos
                                break
                        after = next_rr + 2
                        if after >= len(data):
                            end = len(data)
                            break
                        b0 = data[after]
                        # Handle \r\r\r sub-article delimiter: when b0 is
                        # a third \r, the headword starts at after + 1.
                        hw_after = after
                        if b0 == 0x0D and after + 1 < len(data):
                            hw_after = after + 1
                        hw_b0 = data[hw_after]
                        if 0x61 <= hw_b0 <= 0x7A:
                            # Potential Hebrew headword: check for
                            # Hebrew markers and transliteration brace
                            look = data[hw_after : min(hw_after + 60, len(data))]
                            # Skip if followed by \t (internal divider)
                            if look[:1] == b"\t":
                                end = next_rr + 2
                                continue
                            heb_markers = sum(
                                1
                                for b in look[:30]
                                if b >= 0x80
                                or b in range(0x41, 0x5B)
                                or b in (0x7B, 0x7D)
                            )
                            if heb_markers >= 2:
                                end = next_rr
                                break
                        end = next_rr + 2
                elif self._is_spicq:
                    # Spicq uses \r\r as internal line separators within
                    # entries.  Find the true next-entry boundary: \r\r
                    # followed by a bare Greek headword (starts with a-z,
                    # not ** markers used in body text).
                    end = raw_offset
                    skips = 0
                    while skips < 2000:  # safety limit (Spicq bodies can be long)
                        next_rr = data.find(b"\r\r", end)
                        if next_rr < 0:
                            end = len(data)
                            break
                        # Binary tail guard: if the gap since the last
                        # checked position is large and full of nulls,
                        # we've overshot into the binary tail.  Stop here.
                        gap = next_rr - end
                        if gap > 50000:
                            sample = data[end : min(end + 200, len(data))]
                            nulls = sum(1 for b in sample if b == 0)
                            printable = sum(1 for b in sample if 0x20 <= b <= 0x7E)
                            if nulls > len(sample) // 2 or (
                                len(sample) > 0 and printable < len(sample) // 20
                            ):
                                break  # use current end — don't advance into tail
                        after = next_rr + 2
                        if after >= len(data):
                            end = len(data)
                            break
                        b0 = data[after]
                        if 0x61 <= b0 <= 0x7A:
                            # Potential headword: verify it looks like
                            # Accordance Greek (contains high bytes or
                            # beta-code metacharacters).
                            look = data[after : min(after + 60, len(data))]
                            hw_term = None
                            for term in (0x20, 0x0D, 0x2C):
                                idx = look.find(term)
                                if idx >= 0:
                                    hw_term = idx
                                    break
                            if hw_term is not None and hw_term >= 3:
                                hw_chunk = look[:hw_term]
                                # Strip trailing comma: Spicq headwords
                                # are followed by ", S NNNN;" so the
                                # comma is not part of the headword.
                                if hw_chunk.endswith(b","):
                                    hw_chunk = hw_chunk[:-1]
                                # A real Accordance Greek headword has
                                # either beta-code metacharacters (/ or :)
                                # or interspersed high bytes (high byte
                                # followed by a lowercase letter).  Plain
                                # English transliterations with diacritics
                                # (e.g. anthÕ) have high bytes at the very
                                # end, not interspersed between letters.
                                # Additionally, Spicq uses pure ASCII
                                # transliterations (e.g. dikaios, aiphnidios)
                                # with no diacritics — these are all
                                # lowercase a-z and at least 4 letters.
                                has_beta = any(b in (0x2F, 0x3A) for b in hw_chunk)
                                has_interspersed = any(
                                    hw_chunk[i] >= 0x80
                                    and i + 1 < len(hw_chunk)
                                    and 0x61 <= hw_chunk[i + 1] <= 0x7A
                                    for i in range(len(hw_chunk) - 1)
                                )
                                is_ascii_translit = len(hw_chunk) >= 4 and all(
                                    0x61 <= b <= 0x7A for b in hw_chunk
                                )
                                if has_beta or has_interspersed or is_ascii_translit:
                                    end = next_rr
                                    break
                        end = next_rr + 2
                        skips += 1
                    else:
                        # Safety fallback: 200-skip limit exceeded;
                        # read to EOF rather than incorrectly using
                        # the first \r\r after the headword (which
                        # is an internal line separator, not an
                        # entry boundary).
                        end = len(data)
                elif self._is_ni:
                    # NI Bible Dictionary: entries start with \r + UPPERCASE.
                    # Find the next main entry boundary: a \r that is NOT
                    # part of \r\r and is followed by 2+ uppercase letters.
                    end = raw_offset
                    scan_start = raw_offset + 1
                    MAX_NI_SCAN = 5_000_000
                    while True:
                        next_cr = data.find(b"\r", scan_start)
                        if next_cr < 0 or next_cr - raw_offset > MAX_NI_SCAN:
                            end = min(raw_offset + MAX_NI_SCAN, len(data))
                            break

                        # Skip \r\r sub-entry markers
                        if next_cr + 1 < len(data) and data[next_cr + 1] == 0x0D:
                            scan_start = next_cr + 2
                            continue

                        # Check if followed by 2+ uppercase letters
                        if next_cr + 3 < len(data):
                            b0 = data[next_cr + 1]
                            b1 = data[next_cr + 2]
                            if 0x41 <= b0 <= 0x5A and 0x41 <= b1 <= 0x5A:
                                # Verify it's a real uppercase headword
                                # (all letters until space/paren/CR)
                                j = next_cr + 1
                                while (
                                    j < min(next_cr + 30, len(data))
                                    and 0x41 <= data[j] <= 0x5A
                                ):
                                    j += 1
                                if j - next_cr - 1 >= 2:
                                    end = next_cr
                                    break

                        scan_start = next_cr + 1
                elif self._is_webster:
                    # Webster: entries use \\r\\r as internal line separators
                    # (for quotes, examples, sub-sections).  Find the true
                    # next-entry boundary: next \\r\\r that starts a real headword.
                    # Use the pre-built sorted offset list for fast lookup.
                    offsets = self._webster_entry_offsets
                    if offsets:
                        # raw_offset is the headword start (\\r\\r + 2).
                        # The entry body ends at the next entry's \\r\\r position
                        # (i.e., next headword start - 2).
                        # Binary search for first offset > raw_offset.
                        import bisect

                        idx = bisect.bisect_right(offsets, raw_offset)
                        if idx < len(offsets):
                            # Next entry starts at offsets[idx], its \\r\\r
                            # delimiter is 2 bytes before that.
                            end = offsets[idx] - 2
                        else:
                            end = len(data)
                    else:
                        end = data.find(b"\r\r", raw_offset)
                        if end < 0:
                            end = len(data)
                else:
                    end = data.find(b"\r\r", raw_offset)
                    if end < 0:
                        end = len(data)

        raw = bytes(data[raw_offset:end])
        # Universal binary tail guard: truncate at the first double-null
        # region. Valid entry text never contains consecutive null bytes.
        null_pos = raw.find(b"\x00\x00")
        if null_pos >= 0:
            raw = raw[:null_pos]
        # ── KM (Mounce) raw-byte cleanup ──
        if self._is_km:
            # Strip Mounce \xCA (0xCA) field delimiter bytes before
            # decoding.  These are internal field separators that would
            # otherwise appear as Ê in Latin-1 regions.
            raw = raw.replace(b"\xca", b"")
        if self._is_tdot:
            # TDOT body is primarily English (Latin-1). Filter stray C1
            # control bytes (0x80-0x9F) inherited from Accordance encoding.
            # Also strip \xCA section-delimiter bytes (same as KM fix above).
            raw = raw.replace(b"\xca", b"")
            raw = bytes(b if b < 0x80 or b > 0x9F else 0x20 for b in raw)
            text = (
                raw.decode("latin-1", errors="replace")
                .replace("\r", "\n")
                .replace("\n\n\t", "\n\n**")
                .strip()
            )
            return _postprocess_decoded(text)
        if self._is_utf8:
            return raw.decode("utf-8", errors="replace").replace("\r", "\n").strip()
        if self._is_hebrew:
            # Strip \xCA section-delimiter bytes — these are internal field
            # separators in Hebrew lexicon bodies (HALOT, JW) that would
            # otherwise pass through decode_hebrew as Latin-1 Ê.
            raw = raw.replace(b"\xca", b"")
            text = decode_hebrew(raw).replace("\r", "\n").strip()
            if self._is_km:
                # KM Hebrew has both _is_hebrew and _is_km flags set.
                # Skip past _is_english to reach this point, so strip the
                # version-abbreviation footer line here.
                text = re.split(r"[±\u00B1]", text)[0].strip()
                text = text.rstrip("Ê").strip()
            return text
        if self._is_english:
            # English dictionary modules: simple Latin-1 decode.
            # Strip \xCA section-delimiter bytes — used as internal
            # paragraph separators in NI Bible Dictionary and similar modules.
            raw = raw.replace(b"\xca", b"")
            # Filter stray C1 control bytes (0x80-0x9F) inherited from
            # Accordance 8-bit encoding (curly quotes, apostrophes, etc.)
            raw = bytes(b if b < 0x80 or b > 0x9F else 0x20 for b in raw)
            text = raw.decode("latin-1", errors="replace").replace("\r", "\n").strip()
            return _postprocess_decoded(text)
        if self._is_km:
            text = decode(raw).replace("\r", "\n").strip()
            # Strip version-abbreviation footer lines.
            # Both Mounce Greek ("± mounce | niv | ...") and KM Hebrew
            # ("±niv | esv | csb | ...") use ± to start the footer.
            text = re.split(r"[±\u00B1]", text)[0].strip()
            # Strip any residual Ê characters and trailing whitespace
            text = text.rstrip("Ê").strip()
            return text
        if self._is_numbered:
            return self._decode_numbered_greek_entry(raw).replace("\r", "\n").strip()
        # Greek-encoded (BDAG, Spicq, etc.): use Accordance Greek decoder.
        # Strip \xCA section-delimiter bytes first — they occur as stray
        # formatting in Spicq definitions, otherwise appearing as Ê.
        raw = raw.replace(b"\xca", b"")
        return decode(raw).replace("\r", "\n").strip()

    def headwords(self) -> list[str]:
        """Return all indexed headwords."""
        return sorted(self._entries.keys())

    def find(self, query: str, max_results: int = 20) -> list[str]:
        """Find headwords containing the query string (substring search)."""
        if max_results <= 0:
            return []
        results = []
        if self._is_hebrew:
            normalized = normalize_hebrew_headword(query)
        elif self._is_english:
            normalized = query.lower().rstrip(":;,. \t\r\n")
        else:
            normalized = normalize_headword(query)
            # Strip final sigma variants so substring matching works across
            # both sigma forms (e.g. searching 'λογος' matches 'λογοσ').
            normalized = normalized.rstrip("\u03c2").rstrip("\u03c3")
        query_lower = normalized.lower()
        for hw in self._entries:
            # For matching: also strip final sigma variants from the
            # dictionary key so substring searches are robust.
            hw_cmp = hw.lower()
            if not self._is_hebrew and not self._is_english:
                hw_cmp = hw_cmp.rstrip("\u03c2").rstrip("\u03c3")
            if query_lower in hw_cmp:
                results.append(hw)
                if len(results) >= max_results:
                    break
        return results

    def preview(
        self, headword: str, chars: int = 120, summary_level: str | None = None
    ) -> str:
        """Return a citation-prefixed preview of the entry for `headword`.

        Delegates to lookup() with the appropriate summary_level. The `chars`
        parameter is deprecated; prefer `summary_level` for new code.
        """
        if summary_level is None:
            summary_level = (
                "brief" if chars <= 150 else ("standard" if chars <= 500 else "full")
            )
        result = self.lookup(headword, citation=True, summary_level=summary_level)
        if result is None:
            return f"[No entry found for '{headword}']"
        return result

    def entry_keys(self, headword: str) -> dict[str, list[str]]:
        """Find all index keys that share entry offsets with the given headword.

        Groups keys by type and returns a dict with classification lists:
        ``headwords``, ``strongs``, ``entry_numbers``, ``other``.
        All lists are empty if the headword is not found.
        """
        empty: dict[str, list[str]] = {
            "headwords": [],
            "strongs": [],
            "entry_numbers": [],
            "other": [],
        }

        _resolved_key, target_offsets = self._resolve_entry_key(headword)
        if not target_offsets:
            return empty

        target_offsets_set = set(target_offsets)

        headwords: list[str] = []
        strongs: list[str] = []
        entry_numbers: list[str] = []
        other: list[str] = []

        for k, offsets in self._entries.items():
            if not (set(offsets) & target_offsets_set):
                continue

            if self._STRONGS_RE.match(k):
                strongs.append(k)
            elif self._ENTRY_NUM_RE.match(k):
                entry_numbers.append(k)
            elif self._HEBREW_RE.search(k):
                headwords.append(k)
            elif self._GREEK_RE.search(k):
                headwords.append(k)
            elif self._ASCII_WORD_RE.match(k):
                headwords.append(k)
            else:
                other.append(k)

        # Deduplicate while preserving insertion order
        return {
            "headwords": list(dict.fromkeys(headwords)),
            "strongs": list(dict.fromkeys(strongs)),
            "entry_numbers": list(dict.fromkeys(entry_numbers)),
            "other": list(dict.fromkeys(other)),
        }

    def entry_metadata(self, headword: str) -> dict:
        """Return structured metadata about the entry for `headword`.

        Includes module info, related keys, homograph count, and whether the
        entry was found.
        """
        _resolved_key, offsets = self._resolve_entry_key(headword)
        return {
            "headword": headword,
            "module_abbrev": self.abbrev,
            "module_title": self.title,
            "language": self.language,
            "format": self.format,
            "keys": self.entry_keys(headword),
            "homograph_count": len(offsets),
            "has_entry": self.lookup(headword) is not None,
        }
