"""
Legacy Accordance 8-bit encoding → Unicode for Greek and Hebrew.

BDAG, HALOT, and other .atool lexicon modules use a custom representation
where most Greek letters map to ASCII Latin equivalents (Beta-Code style)
and 8-bit characters (0x80–0xFF) serve as diacritics / modifiers on the
preceding vowel.
"""

import unicodedata

# ── ASCII → basic Greek letter (Beta Code convention) ────────────────
_GREEK_ASCII = {
    ord("a"): "\u03b1",  # α
    ord("b"): "\u03b2",  # β
    ord("g"): "\u03b3",  # γ
    ord("d"): "\u03b4",  # δ
    ord("e"): "\u03b5",  # ε
    ord("z"): "\u03b6",  # ζ
    ord("h"): "\u03b7",  # η
    ord("q"): "\u03b8",  # θ
    ord("i"): "\u03b9",  # ι
    ord("k"): "\u03ba",  # κ
    ord("l"): "\u03bb",  # λ
    ord("m"): "\u03bc",  # μ
    ord("n"): "\u03bd",  # ν
    ord("c"): "\u03c7",  # χ  (chi — Accordance uses c for chi)
    ord("o"): "\u03bf",  # ο
    ord("p"): "\u03c0",  # π
    ord("r"): "\u03c1",  # ρ
    ord("s"): "\u03c3",  # σ
    ord("t"): "\u03c4",  # τ
    ord("u"): "\u03c5",  # υ
    ord("f"): "\u03c6",  # φ
    ord("y"): "\u03c8",  # ψ
    ord("w"): "\u03c9",  # ω
    ord("x"): "\u03be",  # ξ  (xi)
    # Uppercase
    ord("A"): "\u0391",  # Α
    ord("B"): "\u0392",  # Β
    ord("G"): "\u0393",  # Γ
    ord("D"): "\u0394",  # Δ
    ord("E"): "\u0395",  # Ε
    ord("Z"): "\u0396",  # Z
    ord("H"): "\u0397",  # H (capital eta)
    ord("Q"): "\u0398",  # Θ
    ord("I"): "\u0399",  # I
    ord("K"): "\u039a",  # K
    ord("L"): "\u039b",  # Λ
    ord("M"): "\u039c",  # M
    ord("N"): "\u039d",  # N
    ord("C"): "\u03a7",  # X (capital chi)
    ord("O"): "\u039f",  # O
    ord("P"): "\u03a0",  # Π
    ord("R"): "\u03a1",  # P (capital rho)
    ord("S"): "\u03a3",  # Σ (capital sigma)
    ord("T"): "\u03a4",  # T
    ord("U"): "\u03a5",  # Υ
    ord("F"): "\u03a6",  # Φ
    ord("Y"): "\u03a8",  # Ψ
    ord("W"): "\u03a9",  # Ω
    ord("X"): "\u039e",  # Ξ (capital xi)
    # Special
    ord("J"): "\u03b7",  # η  (J used for lowercase eta in some contexts)
    ord("V"): "\u03c2",  # ς  final sigma
    ord("v"): "\u03c2",  # ς  final sigma (v often used for final form)
}

# ── 8-bit diacritic modifiers (applied to preceding vowel) ───────────
# Each entry: byte → (smooth_breathing, roughness, acute)
# where each field is 0 or 1.

_ACCENT_TABLE = {
    # acute accent only
    0xC0: (0, 0, 1),  # ά
    0xD6: (0, 0, 1),  # ί, έ, etc. (alternative acute marker)
    # smooth breathing only
    0xEE: (1, 0, 0),  # ἀ
    0xFA: (1, 0, 0),  # ἰ (variant smooth breathing)
    # smooth + acute
    0xE0: (1, 0, 1),  # ἄ
    0xA6: (1, 0, 1),  # ἔ (variant smooth+acute on epsilon)
    0xEF: (1, 0, 1),  # ὔ (variant smooth+acute on upsilon/omega)
    # rough + acute
    0xE2: (0, 1, 1),  # ἅ
    0xC6: (0, 1, 1),  # ἕ (variant rough+acute)
    0xCE: (0, 1, 1),  # ἵ? (variant rough+acute on iota)
    0xA2: (0, 1, 1),  # ὕ (rough+acute on upsilon, found in NET Notes Greek)
    # rough breathing only
    0xE1: (0, 1, 0),  # ἁ  (rough breathing on alpha)
    0xFB: (0, 1, 0),  # ἱ  (variant rough breathing)
    # grave accent
    0xC1: (0, 0, -1),  # ὰ  (grave accent)
    0xC8: (0, 0, -1),  # ὲ  (variant grave)
    # circumflex
    0xCF: (0, 0, 2),  # ᾶ  (circumflex)
    0xD7: (0, 0, 2),  # ω circumflex variant (×)
    0xDD: (0, 0, 2),  # η circumflex variant
    0xDA: (0, 0, 2),  # circumflex variant
    # iota subscript / diaeresis (handled in _apply_diacritic)
    0xF6: None,
    0xF0: None,  # variant modifier (ignored)
    # macron / overline
    0xAF: None,  # ¯ — abbreviation/overline marker (ignored)
    # other breathing combos
    0xC3: (1, 0, -1),  # smooth+grave (guess)
    0xBC: (0, 1, -1),  # rough+grave (guess)
    0xA5: (0, 1, 0),  # variant rough breathing (guess)
    0xD0: (0, 0, -1),  # grave variant (guess)
}

# Bytes in _ACCENT_TABLE that are actually MacRoman punctuation in
# Latin-1 modules, not genuine Greek diacritics.  Excluding them
# prevents false Greek span detection on MacRoman dashes/quotes.
_MACROMAN_GREEK_OVERLAP = frozenset({0xD0})  # en-dash collides with grave variant

# Set of bytes that are known to be Greek diacritics (for span detection)
_GREEK_DIACRITIC_BYTES = frozenset(
    k for k in _ACCENT_TABLE if k not in _MACROMAN_GREEK_OVERLAP
)

# ── Precomposed Greek vowel table ────────────────────────────────────
# For each base vowel, precomposed forms with different diacritic combos.
# Format: base_vowel_char → {
#     (smooth, rough, accent): precomposed_char
# }
# accent: 0=none, 1=acute, -1=grave, 2=circumflex

_VOWEL_TABLE: dict[str, dict[tuple[int, int, int], str]] = {}


def _build_vowel_table():
    """Build the vowel→precomposed lookup table from Unicode ranges."""
    global _VOWEL_TABLE  # clarifies mutation intent (technically not required since
    # we mutate rather than reassign, but included for explicit intent)
    # Precomposed Greek polytonic: U+1F00-U+1FFF
    # Smooth breathing (psili): offset +0x00
    # Rough breathing (dasia): offset +0x08
    # No breathing: offset +0x10 (acute), offset +0x18 (grave)

    # Build manually for common combinations
    # Format: (base_unicode_char, smooth, rough, accent) → precomposed

    manual = {
        # alpha with smooth breathing
        ("α", 1, 0, 0): "\u1f00",  # ἀ
        ("α", 1, 0, 1): "\u1f04",  # ἄ
        ("α", 1, 0, -1): "\u1f02",  # ἂ
        ("α", 1, 0, 2): "\u1f06",  # ἆ
        # alpha with rough breathing
        ("α", 0, 1, 0): "\u1f01",  # ἁ
        ("α", 0, 1, 1): "\u1f05",  # ἅ
        ("α", 0, 1, -1): "\u1f03",  # ἃ
        ("α", 0, 1, 2): "\u1f07",  # ἇ
        # alpha with no breathing, accented
        ("α", 0, 0, 1): "\u03ac",  # ά
        ("α", 0, 0, -1): "\u1f70",  # ὰ
        ("α", 0, 0, 2): "\u1fb6",  # ᾶ
        # epsilon with breathing
        ("ε", 1, 0, 0): "\u1f10",  # ἐ
        ("ε", 1, 0, 1): "\u1f14",  # ἔ
        ("ε", 1, 0, -1): "\u1f12",  # ἒ
        ("ε", 0, 1, 0): "\u1f11",  # ἑ
        ("ε", 0, 1, 1): "\u1f15",  # ἕ
        ("ε", 0, 1, -1): "\u1f13",  # ἓ
        ("ε", 0, 0, 1): "\u03ad",  # έ
        ("ε", 0, 0, -1): "\u1f72",  # ὲ
        # eta with breathing
        ("η", 1, 0, 0): "\u1f20",  # ἠ
        ("η", 1, 0, 1): "\u1f24",  # ἤ
        ("η", 1, 0, -1): "\u1f22",  # ἢ
        ("η", 1, 0, 2): "\u1f26",  # ἦ
        ("η", 0, 1, 0): "\u1f21",  # ἡ
        ("η", 0, 1, 1): "\u1f25",  # ἥ
        ("η", 0, 1, -1): "\u1f23",  # ἣ
        ("η", 0, 1, 2): "\u1f27",  # ἧ
        ("η", 0, 0, 1): "\u03ae",  # ή
        ("η", 0, 0, -1): "\u1f74",  # ὴ
        ("η", 0, 0, 2): "\u1fc6",  # ῆ
        # iota with breathing
        ("ι", 1, 0, 0): "\u1f30",  # ἰ
        ("ι", 1, 0, 1): "\u1f34",  # ἴ
        ("ι", 1, 0, -1): "\u1f32",  # ἲ
        ("ι", 1, 0, 2): "\u1f36",  # ἶ
        ("ι", 0, 1, 0): "\u1f31",  # ἱ
        ("ι", 0, 1, 1): "\u1f35",  # ἵ
        ("ι", 0, 1, -1): "\u1f33",  # ἳ
        ("ι", 0, 1, 2): "\u1f37",  # ἷ
        ("ι", 0, 0, 1): "\u03af",  # ί
        ("ι", 0, 0, -1): "\u1f76",  # ὶ
        ("ι", 0, 0, 2): "\u1fd6",  # ῖ
        # omicron with breathing
        ("ο", 1, 0, 0): "\u1f40",  # ὀ
        ("ο", 1, 0, 1): "\u1f44",  # ὄ
        ("ο", 1, 0, -1): "\u1f42",  # ὂ
        ("ο", 0, 1, 0): "\u1f41",  # ὁ
        ("ο", 0, 1, 1): "\u1f45",  # ὅ
        ("ο", 0, 1, -1): "\u1f43",  # ὃ
        ("ο", 0, 0, 1): "\u03cc",  # ό
        ("ο", 0, 0, -1): "\u1f78",  # ὸ
        # upsilon with breathing
        ("υ", 1, 0, 0): "\u1f50",  # ὐ
        ("υ", 1, 0, 1): "\u1f54",  # ὔ
        ("υ", 1, 0, -1): "\u1f52",  # ὒ
        ("υ", 1, 0, 2): "\u1f56",  # ὖ
        ("υ", 0, 1, 0): "\u1f51",  # ὑ
        ("υ", 0, 1, 1): "\u1f55",  # ὕ
        ("υ", 0, 1, -1): "\u1f53",  # ὓ
        ("υ", 0, 1, 2): "\u1f57",  # ὗ
        ("υ", 0, 0, 1): "\u03cd",  # ύ
        ("υ", 0, 0, -1): "\u1f7a",  # ὺ
        ("υ", 0, 0, 2): "\u1fe6",  # ῦ
        # omega with breathing
        ("ω", 1, 0, 0): "\u1f60",  # ὠ
        ("ω", 1, 0, 1): "\u1f64",  # ὤ
        ("ω", 1, 0, -1): "\u1f62",  # ὢ
        ("ω", 1, 0, 2): "\u1f66",  # ὦ
        ("ω", 0, 1, 0): "\u1f61",  # ὡ
        ("ω", 0, 1, 1): "\u1f65",  # ὥ
        ("ω", 0, 1, -1): "\u1f63",  # ὣ
        ("ω", 0, 1, 2): "\u1f67",  # ὧ
        ("ω", 0, 0, 1): "\u03ce",  # ώ
        ("ω", 0, 0, -1): "\u1f7c",  # ὼ
        ("ω", 0, 0, 2): "\u1ff6",  # ῶ
        # Rho with breathing
        ("ρ", 1, 0, 0): "\u1fe4",  # ῤ
        ("ρ", 0, 1, 0): "\u1fe5",  # ῥ
        ("Ρ", 0, 1, 0): "\u1fec",  # Ῥ (capital rho with rough breathing)
    }

    for (base, smooth, rough, accent), char in manual.items():
        if base not in _VOWEL_TABLE:
            _VOWEL_TABLE[base] = {}
        _VOWEL_TABLE[base][(smooth, rough, accent)] = char


_build_vowel_table()


def _apply_diacritic(base_char: str, diacritic_byte: int) -> str | None:
    """Apply a diacritic byte to the preceding Greek character.

    Returns the precomposed Unicode character, or None if the combination
    is not supported.
    """
    # 0xF6 = iota subscript (on α/η/ω) or diaeresis (on ι/υ)
    if diacritic_byte == 0xF6:
        nfd = unicodedata.normalize("NFD", base_char)
        base = nfd[0] if nfd else base_char
        if base in ("\u03b1", "\u03b7", "\u03c9", "\u0391", "\u0397", "\u03a9"):
            return base_char + "\u0345"
        if base in ("\u03b9", "\u03c5", "\u0399", "\u03a5"):
            return base_char + "\u0308"
        return None

    rule = _ACCENT_TABLE.get(diacritic_byte)
    if rule is None:
        return None
    smooth, rough, accent = rule
    table = _VOWEL_TABLE.get(base_char)
    if table is None:
        return None
    return table.get((smooth, rough, accent))


def _find_greek_spans(data: bytes, trench: bool = False) -> list[tuple[int, int]]:
    """Find byte ranges that contain 8-bit Greek diacritic characters.

    Returns a list of (start, end) byte offsets that should be decoded
    as Greek.  Regions without 8-bit characters are treated as Latin/ASCII.

    A Greek span starts at an 8-bit diacritic byte and extends outward
    through adjacent word-forming characters.  Spans end at null bytes,
    carriage returns, or stretches of pure ASCII longer than a threshold.

    When trench=True, Trench-specific Greek indicators (J, v after u)
    are also treated as span position seeds.
    """
    # First pass: collect all Greek diacritic positions (excluding § delimiter)
    greek_positions = {i for i, b in enumerate(data) if b in _GREEK_DIACRITIC_BYTES}

    # Also collect Beta Code accent markers (/ = acute, \ = grave) which
    # indicate Greek text even without 8-bit diacritics nearby.
    greek_positions |= {i for i, b in enumerate(data) if b in (0x2F, 0x5C)}

    # ── Trench-specific: J (rough breathing) and v-after-u (circumflex) ──
    # These are standalone ASCII bytes that encode Greek diacritics in
    # Trench files.  Without them as indicators, isolated Greek words
    # without 8-bit diacritics (like oJ = ó, douvloς = δοῦλος) would be
    # decoded as Latin-1.
    if trench:
        _trench_lower = frozenset(range(0x61, 0x7B))  # a-z
        for i, b in enumerate(data):
            if b == 0x4A:  # J = rough breathing marker
                # Only seed if surrounded by lowercase letters (Greek context)
                if i > 0 and data[i - 1] in _trench_lower:
                    greek_positions.add(i)
                elif i + 1 < len(data) and data[i + 1] in _trench_lower:
                    greek_positions.add(i)
                elif i + 1 < len(data) and data[i + 1] >= 0x80:
                    greek_positions.add(i)
            elif b == 0x76:  # v = circumflex on preceding u in Trench
                # In Trench, v after u ALWAYS encodes upsilon+circumflex.
                # Seed everywhere so isolated words like douvloς or touv
                # are captured.  English "uv" patterns (curve, uvula)
                # are virtually non-existent in biblical commentary text,
                # and the GAP check with uppercase/digit detection
                # prevents false merges with English regions.
                if i > 0 and data[i - 1] == 0x75:  # preceded by u
                    greek_positions.add(i)

    if not greek_positions:
        return []

    # Merge nearby Greek positions into contiguous spans.
    # A span breaks when there are >= GAP consecutive bytes without an 8-bit char
    # AND those bytes contain a clear English marker (uppercase letter or digit).
    GAP = 25  # bytes between Greek chars to trigger potential split

    spans = []
    sorted_pos = sorted(greek_positions)
    span_start = sorted_pos[0]
    prev_pos = sorted_pos[0]

    for pos in sorted_pos[1:]:
        gap_start = prev_pos + 1
        gap_end = pos
        gap_size = gap_end - gap_start
        if gap_size > GAP:
            gap_bytes = data[gap_start:gap_end]
            has_english = any(
                b in range(0x41, 0x5B) or b in range(0x30, 0x3A) for b in gap_bytes
            )
            # Also detect English body text via consecutive lowercase ASCII runs.
            # Gaps > 25 bytes between Greek diacritics that contain 3+
            # consecutive lowercase a-z letters are English words/transliterations,
            # not Greek Beta Code (which would have diacritics every few chars).
            # Without this check, all-lowercase English body text between Greek
            # diacritics gets merged into Greek spans and decoded as Greek chars.
            if not has_english:
                consecutive_lower = 0
                for b in gap_bytes:
                    if 0x61 <= b <= 0x7A:  # a-z
                        consecutive_lower += 1
                        if consecutive_lower >= 3:
                            has_english = True
                            break
                    else:
                        consecutive_lower = 0
            if has_english:
                spans.append((span_start, prev_pos + 1))
                span_start = pos
        prev_pos = pos

    # Final span
    spans.append((span_start, prev_pos + 1))

    # Extend each span outward through adjacent ASCII letters A-Z and a-z
    # (backward then forward) so diacritics can modify surrounding letters.
    # In trench mode, also extend through J (0x4A) since J after a vowel
    # encodes rough breathing (e.g. oJ = ó).
    _extend_bytes = frozenset(range(0x41, 0x5B)) | frozenset(
        range(0x61, 0x7B)
    )  # A-Z + a-z
    if trench:
        _extend_bytes = _extend_bytes | {
            0x4A,
            0x76,
        }  # + J + v (redundant with ranges; kept for clarity)

    extended = []
    for start, end in spans:
        # Extend backward through extension bytes
        ns = start
        while ns > 0 and data[ns - 1] in _extend_bytes:
            ns -= 1
            if start - ns > 20:
                break
        # Extend forward through extension bytes
        ne = end
        while ne < len(data) and data[ne] in _extend_bytes:
            ne += 1
        extended.append((ns, ne))

    # Merge overlapping spans
    if not extended:
        return []
    merged = [extended[0]]
    for sp in extended[1:]:
        if sp[0] < merged[-1][1]:
            merged[-1] = (merged[-1][0], max(merged[-1][1], sp[1]))
        else:
            merged.append(sp)
    return merged


# ── MacRoman byte → Latin-1 byte pre-conversion ────────────────────
# Many Accordance Latin-1 modules use MacRoman encoding for accented
# characters (Spanish á/é/í/ó/ú/ñ, Romanian, German, etc.).  These
# bytes land in the 0x80-0x9F range which _sanitize_latin1 would
# otherwise obliterate.  Convert them to their Latin-1 equivalents
# first so they survive sanitization and decode correctly.
# ═══════════════════════════════════════════════════════════════════════════
# MacRoman → Latin-1 mapping for pre-conversion of Latin-1 text regions.
#
# RELATED TABLES (constants.py):
#   _TITLE_MAC_ROMAN_MAP   — MacRoman→Unicode for module titles
#   _MACROMAN_TO_UNICODE   — MacRoman→Unicode for verse text bodies
# These three tables share the same 0x80-0x9F MacRoman byte meanings.
# Changes to one should be checked against the other two.
# ═══════════════════════════════════════════════════════════════════════════
_MACROMAN_TO_LATIN1 = {
    # Standard Mac OS Roman encoding for 0x80-0x9F.
    # These bytes in Accordance Latin-1 modules represent MacRoman
    # accented characters.  We convert them to Latin-1 equivalents
    # so they survive _sanitize_latin1 and decode correctly.
    0x80: 0xC4,  # Ä
    0x81: 0xC5,  # Å
    0x82: 0xC7,  # Ç
    0x83: 0xC9,  # É
    0x84: 0xD1,  # Ñ
    0x85: 0xD6,  # Ö
    0x86: 0xDC,  # Ü
    0x87: 0xE1,  # á
    0x88: 0xE0,  # à
    0x89: 0xE2,  # â
    0x8A: 0xE4,  # ä
    0x8B: 0xE3,  # ã
    0x8C: 0xE5,  # å
    0x8D: 0xE7,  # ç
    0x8E: 0xE9,  # é
    0x8F: 0xE8,  # è
    0x90: 0xEA,  # ê
    0x91: 0xEB,  # ë
    0x92: 0xED,  # í
    0x93: 0xEC,  # ì
    0x94: 0xEE,  # î
    0x95: 0xEF,  # ï
    0x96: 0xF1,  # ñ
    0x97: 0xF3,  # ó
    0x98: 0xF2,  # ò
    0x99: 0xF4,  # ô
    0x9A: 0xF6,  # ö
    0x9B: 0xF5,  # õ
    0x9C: 0xFA,  # ú
    0x9D: 0xF9,  # ù
    0x9E: 0xFB,  # û
    0x9F: 0xFC,  # ü
    # Bytes above 0x9F that are MacRoman punctuation in Latin-1 modules
    0xD0: 0x2D,  # – en-dash → ASCII hyphen (prevents false Greek detection)
    # ── MacRoman high bytes (0xA0-0xFF) → Latin-1 equivalents ──────
    # Many Accordance Latin-1 modules use MacRoman encoding throughout.
    # These bytes have a DIFFERENT meaning in MacRoman vs Latin-1;
    # converting them ensures accented characters and punctuation
    # decode correctly before _sanitize_latin1 runs.
    #
    # NOTE: bytes 0xC0, 0xCF, 0xEE, 0xF6, 0xA8, 0xB7 are INTENTIONALLY
    # excluded — they are Accordance transliteration markers that must
    # survive as their Latin-1 interpretation for _postprocess_decoded.
    # A0 row
    0xA1: 0xB0,  # ° (degree)
    0xA4: 0xA7,  # § (section)
    0xA6: 0xB6,  # ¶ (pilcrow)
    # 0xA7 (MacRoman ß) NOT mapped — it is the Accordance final-sigma
    # marker (0xA7 → § → ς) and must not be converted before decode().
    0xAB: 0xB4,  # ´ (acute)
    0xAC: 0xA8,  # ¨ (diaeresis)
    0xAE: 0xC6,  # Æ
    0xAF: 0xD8,  # Ø
    # B0 row
    0xB1: 0xB1,  # ±  (identity mapping; MacRoman and Latin-1 agree)
    0xB4: 0xA5,  # ¥ (yen)
    0xB5: 0xB5,  # µ  (identity mapping; MacRoman and Latin-1 agree)
    0xBB: 0xAA,  # ª (feminine ordinal)
    0xBC: 0xBA,  # º (masculine ordinal)
    0xBE: 0xE6,  # æ
    0xBF: 0xF8,  # ø
    # C0 row
    0xC1: 0xA1,  # ¡ (inverted exclamation)
    0xC2: 0xAC,  # ¬ (not sign)
    0xC7: 0xAB,  # « (left guillemet)
    0xC8: 0xBB,  # » (right guillemet)
    0xC9: 0xB0,  # … (ellipsis → degree sign bridge, replaced in post-process)
    0xCA: 0xA0,  # NBSP
    0xCB: 0xC0,  # À (A-grave)
    0xCC: 0xC3,  # Ã (A-tilde)
    0xCD: 0xD5,  # Õ (O-tilde)
    # D0 row
    0xD1: 0x2D,  # — em-dash → ASCII hyphen (no Latin-1 equivalent)
    0xD2: 0x22,  # " left double quote → ASCII double quote
    0xD3: 0x22,  # " right double quote → ASCII double quote
    0xD4: 0x27,  # ' left single quote → ASCII apostrophe
    0xD5: 0x27,  # ' right single quote → ASCII apostrophe
    0xD6: 0xF7,  # ÷ (division)
    0xD8: 0xFF,  # ÿ (y-diaeresis)
    0xDA: 0x2F,  # fraction slash → ASCII forward slash
    0xDB: 0xA4,  # ¤ currency → Latin-1 currency sign
    # E0 row
    0xE1: 0xB7,  # · (middle dot) — but allow override by _POST_PROCESS_MAP
    0xE5: 0xC2,  # Â (A-circumflex)
    0xE6: 0xCA,  # Ê (E-circumflex)
    0xE7: 0xC1,  # Á (A-acute)
    0xE8: 0xCB,  # Ë (E-diaeresis)
    0xE9: 0xC8,  # È (E-grave)
    0xEA: 0xCD,  # Í (I-acute)
    0xEB: 0xCE,  # Î (I-circumflex)
    0xEC: 0xCF,  # Ï (I-diaeresis)
    0xED: 0xCC,  # Ì (I-grave)
    0xEF: 0xD4,  # Ô (O-circumflex)
    # F0 row
    0xF1: 0xD2,  # Ò (O-grave)
    0xF2: 0xDA,  # Ú (U-acute)
    0xF3: 0xDB,  # Û (U-circumflex)
    0xF4: 0xD9,  # Ù (U-grave)
    0xF8: 0xAF,  # ¯ (macron)
    0xFC: 0xB8,  # ¸ (cedilla)
    # Bytes NOT mapped above either have no Latin-1 equivalent
    # (e.g. MacRoman œ ˆ ˘) or are Accordance transliteration
    # markers (0xC0=À, 0xCF=Ï, 0xEE=î, 0xF6=ö, 0xA8=¨, 0xB7=·).
    # These are handled in _postprocess_decoded.
    # Note: MacRoman … (0xC9) is mapped above via the 0xB0 degree-sign bridge.
    # MacRoman smart quotes (0xD2-0xD5) and em-dash (0xD1) are mapped to
    # ASCII fallbacks (0x22/0x27/0x2D) since they have no Latin-1 equivalents.
    # Fraction slash (0xDA) maps to ASCII /. Currency (0xDB) maps to Latin-1 0xA4.
}


def _pre_convert_macroman(data: bytes) -> bytes:
    """Convert known MacRoman bytes in 0x80-0x9F to Latin-1 equivalents.

    Called on Latin-1 regions before _sanitize_latin1 so accented
    characters survive the C1-control sanitization pass.
    """
    result = bytearray(data)
    for i in range(len(result)):
        repl = _MACROMAN_TO_LATIN1.get(result[i])
        if repl is not None:
            result[i] = repl
    return bytes(result)


def _sanitize_latin1(data: bytes) -> bytes:
    """Replace C1 control bytes (0x80-0x9F) with space in Latin-1 regions.

    These bytes are Accordance encoding artifacts that fell outside
    Hebrew/Greek span detection. Replacing with space is consistent
    with how \\r (0x0D) is handled throughout the decoder.
    """
    result = bytearray(data)
    for i in range(len(result)):
        if 0x80 <= result[i] <= 0x9F:
            result[i] = 0x20
    return bytes(result)


# ── Post-processing: fix encoding artifacts in Latin-1 text regions ─
# These bytes fall outside the Greek/Hebrew diacritic tables and pass
# through the decoder as raw Latin-1 characters.  The map below replaces
# them with their intended Unicode equivalents.
_POST_PROCESS_MAP = {
    # Curly/smart quotes used around English glosses and quoted terms
    "\xd4": "\u201c",  # 0xD4 (Ô) → left double quote
    "\xd5": "\u201d",  # 0xD5 (Õ) → right double quote
    "\xd2": "\u201c",  # 0xD2 (Ò) → left double quote (variant)
    "\xd3": "\u201d",  # 0xD3 (Ó) → right double quote (variant)
    # Em dash used as separator/citation marker (e.g., "cf. lit.ÑCFabr")
    "\xd1": "\u2014",  # 0xD1 (Ñ) → em dash
    # Standalone acute accent artifact in Hebrew lexicon entries (BDB)
    "\xb4": "",  # 0xB4 (´) → strip
    # Non-standard parenthetical marker in TDNT headword entries
    "\xf9": "",  # 0xF9 (ù) → strip
    # Non-breaking space
    "\xa0": " ",  # 0xA0 → regular space
    # MacRoman mappings (bytes > 0x9F that survive _sanitize_latin1)
    "\xb0": "\u2026",  # 0xB0 → … (ellipsis)
    "\xd0": "\u2013",  # 0xD0 → – (en-dash, used for number ranges)
    "\xa5": "\u2022",  # 0xA5 → • (bullet, used for list items)
}

# ── Transliteration diacritic replacements (regex-based) ──────────
# Accordance Bible dictionaries use a custom encoding for Hebrew
# pronunciation guides.  Certain MacRoman glyphs were repurposed as
# stand-ins for vowel-length diacritics (macron, breve, diaeresis).
# These characters survive Latin-1 decoding and need context-aware
# replacement to produce correct Unicode transliterations.
#
# Each entry: (regex_pattern, replacement) applied in order.
# Patterns use the Latin-1 decoded characters, NOT raw bytes.
_TRANSLITERATION_REPLACEMENTS: list[tuple[str, str]] = [
    # À (U+00C0, A-grave): Accordance stress/separator marker.
    # Strip this FIRST so it doesn't block other diacritics from
    # combining with the intended character.
    # (e.g. "nÀö" → strip À → "nö" → macron on n → "n̄")
    (r"\xc0", ""),
    # Ï (U+00CF, I-diaeresis) after a vowel → combining macron on that vowel.
    # Used in Hebrew pronunciation: "heÏ"→"hē", "beÏ"→"bē", "roÏ"→"rō",
    # "aÏ"→"ā", "iÏ"→"ī", "uÏ"→"ū", "laÏ"→"lā".
    (r"([aeiouAEIOU])\xcf", r"\1" + "\u0304"),
    # Ï after any other letter: still apply macron (edge case)
    (r"([A-Za-z])\xcf", r"\1" + "\u0304"),
    # ö (U+00F6, o-diaeresis) after a letter → combining macron.
    # Used after consonants to mark the syllable's vowel as long:
    # "nö"→"n̄", "kö"→"k̄", "rö"→"r̄", "pö"→"p̄".
    # Only apply to letters, not hyphens or other punctuation.
    (r"([A-Za-z])\xf6", r"\1" + "\u0304"),
    # ö not after a letter: strip it (it was a formatting artifact)
    (r"\xf6", ""),
    # Ö (U+00D6, O-diaeresis) after a letter → combining acute accent.
    # Used in Greek name transliteration: "PeÖtra"→"Pétra".
    (r"([A-Za-z])\xd6", r"\1" + "\u0301"),
    # Ö not after a letter: strip it
    (r"\xd6", ""),
    # ¨ (U+00A8, diaeresis) after a letter → combining diaeresis.
    # Used in Hebrew forms: "lu¨ah"→"lüah", "rivqa¨h"→"rivqäh".
    (r"([A-Za-z])\xa8", r"\1" + "\u0308"),
]


def _postprocess_decoded(text: str) -> str:
    """Replace known encoding artifacts in Latin-1 text regions."""
    import re

    # Strip C0 control characters (0x00-0x1F) except \t (0x09),
    # \n (0x0A), and \r (0x0D).  These are bytes from Accordance's
    # binary format that survive the decode pipeline and appear as
    # stray control characters in user-facing text.
    text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F]", "", text)

    # Strip Accordance internal formatting delimiters (0x7B/0x7D).
    # These bracket Hebrew root letters and transliterations in
    # English body text (TDOT, Thayer, NI Bible Dictionary, etc.).
    text = text.replace("{", "").replace("}", "")

    # Phase 1: Simple character-level replacements
    for artifact, replacement in _POST_PROCESS_MAP.items():
        text = text.replace(artifact, replacement)

    # Phase 2: Regex-based transliteration diacritic replacements.
    # These handle characters that represent combining diacritics
    # applied to the PRECEDING character in Accordance's custom
    # transliteration encoding.
    for pattern, replacement in _TRANSLITERATION_REPLACEMENTS:
        text = re.sub(pattern, replacement, text)

    return text


def decode(data: bytes, trench: bool = False, no_greek: bool = False) -> str:
    """Decode a byte sequence from the legacy Accordance encoding to Unicode.

    Uses a two-pass approach: first identifies Greek text regions
    (those containing 8-bit diacritic characters), then decodes only
    those regions.  ASCII text without diacritics passes through as
    Latin-1.  Stray C1 bytes (0x80-0x9F) in Latin-1 regions are
    replaced with space to prevent control characters in output.

    When trench=True, Trench-specific encoding (J/j as breathing
    markers, v after u as circumflex) is handled.
    When no_greek=True, Greek span detection is skipped entirely
    (useful for modules known to contain no Greek text).
    """
    spans = [] if no_greek else _find_greek_spans(data, trench=trench)
    result = []
    pos = 0

    for start, end in spans:
        if pos < start:
            latin = data[pos:start]
            latin = _pre_convert_macroman(latin)
            latin = _sanitize_latin1(latin)
            result.append(latin.decode("latin-1", errors="replace"))

        greek_bytes = data[start:end]
        greek_result = _decode_greek(greek_bytes, trench=trench)
        result.append(greek_result)
        pos = end

    if pos < len(data):
        latin = data[pos:]
        latin = _pre_convert_macroman(latin)
        latin = _sanitize_latin1(latin)
        result.append(latin.decode("latin-1", errors="replace"))

    text = "".join(result).replace("\xa7", "\u03c2")
    return _postprocess_decoded(text)


def decode_always(data: bytes, trench: bool = False) -> str:
    """Decode bytes as pure Accordance Greek encoding (no Latin fallback).

    Unlike decode(), this treats all ASCII letters as Greek regardless
    of diacritic presence.  Use for headword strings that are known to
    be Greek.

    When trench=True, Trench-specific encoding is handled.
    """
    text = _decode_greek(data, trench=trench)
    return _postprocess_decoded(text)


def normalize_headword(headword: str) -> str:
    """Strip grammatical markers and diacritics from a Greek headword.

    Removes '/' separators and strips combining diacritics
    so that 'λόγος' ≈ 'λογος' ≈ 'λογο' for lookup purposes.
    """
    # Remove / markers
    hw = headword.replace("/", "").rstrip("§")
    # Normalize to NFD (decompose) and strip combining marks
    nfd = unicodedata.normalize("NFD", hw)
    base = "".join(c for c in nfd if not unicodedata.combining(c))
    return base


def _decode_greek(data: bytes, trench: bool = False) -> str:
    """Decode a segment known to be in the Accordance Greek encoding.

    When trench=True, Trench-specific encoding quirks are handled:
      - J (0x4A) after a vowel/rho → rough breathing on that letter
      - j (0x6A) after a vowel → smooth breathing on that letter
      - v (0x76) after u → circumflex accent (not final sigma)
    """
    result = []
    i = 0
    while i < len(data):
        b = data[i]

        # § (0xA7) = final sigma (ς) in Accordance 8-bit Greek encoding
        if b == 0xA7:
            result.append("\u03c2")
            i += 1
            continue

        # 8-bit range: try as diacritic modifier on previous char
        if 0x80 <= b <= 0xFF:
            if result:
                prev = result[-1]
                modified = _apply_diacritic(prev, b)
                if modified is not None:
                    result[-1] = modified
            i += 1
            continue

        # ── Breathing markers (J = rough, j = smooth) ────────────
        # J and j are standard Accordance Beta Code breathing markers,
        # not Trench-specific.  They apply to the preceding vowel/rho.
        # (v = circumflex is Trench-only; see below.)
        if b in (0x4A, 0x6A) and result:
            prev = result[-1]
            prev_nfd = unicodedata.normalize("NFD", prev)
            base = prev_nfd[0] if prev_nfd else ""
            # Vowels and rho that can receive breathing marks
            _breathing_bases = {
                "\u03b1",
                "\u03b5",
                "\u03b7",
                "\u03b9",  # α ε η ι
                "\u03bf",
                "\u03c5",
                "\u03c9",  # ο υ ω
                "\u03c1",  # ρ
                "\u0391",
                "\u0395",
                "\u0397",
                "\u0399",  # Α Ε Η Ι
                "\u039f",
                "\u03a5",
                "\u03a9",  # Ο Υ Ω
                "\u03a1",  # Ρ
            }
            if b == 0x4A and base in _breathing_bases:  # J = rough breathing
                table = _VOWEL_TABLE.get(base)
                if table:
                    modified = table.get((0, 1, 0))  # rough breathing, no accent
                    if modified is not None:
                        result[-1] = modified
                        i += 1
                        continue
            elif b == 0x6A and base in _breathing_bases:  # j = smooth breathing
                table = _VOWEL_TABLE.get(base)
                if table:
                    modified = table.get((1, 0, 0))  # smooth breathing, no accent
                    if modified is not None:
                        result[-1] = modified
                        i += 1
                        continue

        # ── Trench-specific: v after u/long vowel = circumflex ────
        if trench and b == 0x76 and result:
            prev = result[-1]
            prev_nfd = unicodedata.normalize("NFD", prev)
            base = prev_nfd[0] if prev_nfd else ""
            if base in {
                "\u03b1",
                "\u03b7",
                "\u03b9",
                "\u03c5",
                "\u03c9",  # α η ι υ ω
                "\u0391",
                "\u0397",
                "\u0399",
                "\u03a5",
                "\u03a9",  # Α Η Ι Υ Ω
            }:  # v = circumflex on long vowel in Trench
                table = _VOWEL_TABLE.get(base)
                if table:
                    modified = table.get((0, 0, 2))  # circumflex
                    if modified is not None:
                        result[-1] = modified
                        i += 1
                        continue
            # v not acting as circumflex: pass through as literal 'v'
            # rather than falling through to _GREEK_ASCII which maps
            # 0x76 → final sigma (ς).  Without this else, v after a
            # consonant in Trench mode silently becomes ς.
            result.append(chr(b))
            i += 1
            continue

        # ASCII range: try Greek mapping
        greek = _GREEK_ASCII.get(b)
        if greek is not None:
            result.append(greek)
            i += 1
            continue

        # Beta Code accent markers: / = acute, \ = grave
        if b in (0x2F, 0x5C) and result:
            prev = result[-1]
            accent = 1 if b == 0x2F else -1
            table = _VOWEL_TABLE.get(prev)
            if table:
                precomposed = table.get((0, 0, accent))
                if precomposed:
                    result[-1] = precomposed
            i += 1
            continue

        if 0x20 <= b < 0x7F:
            result.append(chr(b))
        elif b == 0x0D:
            result.append(" ")
        # else: skip control chars

        i += 1

    return "".join(result)


# ═══════════════════════════════════════════════════════════════════════
# Hebrew encoding
# ═══════════════════════════════════════════════════════════════════════
#
# Accordance Hebrew modules (type 4 .atool) use an 8-bit encoding where
# ASCII letters represent Hebrew consonants (Michigan-Claremont convention)
# and 8-bit bytes (0x80–0xFF) serve as vowel-point / diacritic modifiers
# applied to the preceding consonant.

_HEBREW_ASCII = {
    # ── Lowercase consonants ──
    ord("a"): "\u05d0",  # א  aleph
    ord("b"): "\u05d1",  # ב  bet
    ord("g"): "\u05d2",  # ג  gimel
    ord("d"): "\u05d3",  # ד  dalet
    ord("h"): "\u05d4",  # ה  he
    ord("w"): "\u05d5",  # ו  vav  (also holem marker: handled contextually)
    ord("z"): "\u05d6",  # ז  zayin
    ord("x"): "\u05d7",  # ח  chet
    ord("j"): "\u05d9",  # י  yod
    ord("y"): "\u05d9",  # י  yod  (variant)
    ord("f"): "\u05d8",  # ט  tet  (Accordance-specific)
    ord("k"): "\u05db",  # כ  kaf
    ord("l"): "\u05dc",  # ל  lamed
    ord("m"): "\u05de",  # מ  mem
    ord("n"): "\u05e0",  # נ  nun
    ord("s"): "\u05e1",  # ס  samekh
    ord("p"): "\u05e4",  # פ  pe
    ord("c"): "\u05e6",  # צ  tsadi
    ord("q"): "\u05e7",  # ק  qof
    ord("r"): "\u05e8",  # ר  resh
    ord("v"): "\u05e9",  # שׁ  shin  (context-dependent: sheva between consonants)
    ord("t"): "\u05ea",  # ת  tav
    ord("o"): "\u05e2",  # ע  ayin
    # ── Final (sof it) forms (uppercase) ──
    ord("K"): "\u05da",  # ך  final kaf
    ord("M"): "\u05dd",  # ם  final mem
    ord("N"): "\u05df",  # ן  final nun
    ord("P"): "\u05e3",  # ף  final pe
    ord("C"): "\u05e5",  # ץ  final tsadi
}

# Uppercase ASCII letters that serve as vowel markers (not consonants).
_HEBREW_VOWEL_CHARS = {
    ord("A"): "\u05b7",  # patach  ַ
    ord("E"): "\u05b6",  # segol   ֶ
    ord("I"): "\u05b4",  # chirik  ִ
    ord("O"): "\u05b9",  # holem   ֹ
    ord("U"): "\u05bb",  # qubuts  ֻ
}

# Uppercase letters that are form/variant markers — stripped during decoding.
_HEBREW_FORM_MARKERS = frozenset(
    {
        0x44,  # D
        0x46,  # F
        0x4A,  # J
        0x51,  # Q
        0x52,  # R
        0x53,  # S
        0x54,  # T
        0x56,  # V
        0x58,  # X
        0x5A,  # Z
    }
)

# 8-bit vowel-point / diacritic modifiers applied to the preceding consonant.
# Format: byte → combining Unicode character
_HEBREW_POINTS = {
    0x81: "\u05b7",  # patach  ַ
    0xC3: "\u05b0",  # sheva   ְ
    0xEB: "\u05b6",  # segol   ֶ
    0xF6: "\u05b4",  # chirik  ִ
    0xAB: "\u05b5",  # tsere   ֵ
    0xC9: "\u05bc",  # dagesh / mappiq  ּ
    0xD7: "\u05b9",  # holem   ֹ
    0xB6: "\u05b8",  # qamats  ָ
    0xBB: "\u05bb",  # qubuts  ֻ
    0xA5: "\u05c1",  # shin dot  ׁ
    0xA6: "\u05c2",  # sin dot   ׂ
    0xAF: None,  # macron/overline — ignored
    0xF0: None,  # formatting marker — ignored
    # Additional diacritics observed in HALOT data
    0x87: "\u05b0",  # sheva variant
    0x8C: "\u05b7",  # patach variant
    0x8E: "\u05b6",  # segol variant
    0x94: None,  # formatting
    0x9F: None,  # formatting
    0xA8: None,  # formatting
    0xAC: "\u05b5",  # tsere variant
    0xB4: "\u05b4",  # chirik variant
    0xBD: None,  # formatting
    0xBF: None,  # formatting
    0xC4: None,  # formatting
    0xE4: "\u05b6",  # segol variant
    0xF2: None,  # formatting marker
}

# Bytes known to be Hebrew diacritics (for span detection)
_HEBREW_DIACRITIC_BYTES = frozenset(_HEBREW_POINTS.keys())


def _apply_hebrew_point(base_char: str, point_byte: int) -> str | None:
    """Apply a Hebrew vowel-point/dagesh byte to a preceding Hebrew character."""
    point = _HEBREW_POINTS.get(point_byte)
    if point is None:
        return None
    return base_char + point


def _find_hebrew_spans(data: bytes) -> list[tuple[int, int]]:
    """Find byte ranges likely to contain Hebrew-encoded text.

    Returns (start, end) byte offsets that should be decoded as Hebrew.
    Detects Hebrew via 8-bit diacritics, uppercase vowel markers (A/E/I/O/U),
    or final-form letters (K/M/N/P/C) adjacent to Hebrew consonants.
    """
    # Bytes that indicate a Hebrew region when adjacent to a consonant:
    # 8-bit diacritics, uppercase vowel markers, and final-form letters.
    _HEBREW_INDICATORS = _HEBREW_DIACRITIC_BYTES | {
        0x41,
        0x45,
        0x49,
        0x4F,
        0x55,  # A, E, I, O, U vowel markers
        0x4B,
        0x4D,
        0x4E,
        0x50,
        0x43,  # K, M, N, P, C final forms
    }
    # Hebrew-related uppercase letters that should NOT trigger English detection.
    # Includes vowel markers, final forms, and form markers.
    _HEBREW_UPPERCASE = _HEBREW_INDICATORS | {
        0x44,
        0x46,
        0x4A,
        0x51,
        0x52,
        0x53,
        0x54,
        0x56,
        0x58,
        0x5A,
    }

    hebrew_consonant_bytes = set(_HEBREW_ASCII.keys())
    # All Hebrew-related bytes that should be included in a Hebrew span:
    # consonants, vowel markers, final forms, form markers, diacritics,
    # and internal separators (semicolons between root letters, commas
    # between alternate forms).
    _HEBREW_SEP_BYTES = {0x3B, 0x2C}  # ; ,
    hebrew_letter_bytes = hebrew_consonant_bytes | _HEBREW_UPPERCASE | _HEBREW_SEP_BYTES

    positions = set()
    for i, b in enumerate(data):
        if b in _HEBREW_INDICATORS:
            # Count as a Hebrew position if within 2 bytes of a Hebrew consonant
            # or adjacent to another Hebrew indicator byte.
            near_hebrew = False
            for j in range(max(0, i - 2), min(len(data), i + 3)):
                if j == i:
                    continue
                if data[j] in hebrew_consonant_bytes or (
                    j != i and data[j] in _HEBREW_INDICATORS
                ):
                    near_hebrew = True
                    break
            if near_hebrew:
                # Guard against false positives in English body text.
                # 8-bit diacritics (b >= 0x80) only appear in real Hebrew
                # text and are always trusted.  Uppercase ASCII indicators
                # (A/E/I/O/U/K/M/N/P/C) are ambiguous: they can be Hebrew
                # vowel markers in headwords, or ordinary English letters
                # in definition text.  In real Hebrew, indicators cluster
                # densely (roughly 20-50% of bytes).  In English text they
                # are sparse (<5%).  Require >=15% indicator density within
                # a +/-30 byte window for uppercase indicators.
                if b < 0x80:
                    window_start = max(0, i - 30)
                    window_end = min(len(data), i + 31)
                    window_size = window_end - window_start
                    if window_size > 0:
                        indicator_count = sum(
                            1
                            for j in range(window_start, window_end)
                            if data[j] in _HEBREW_INDICATORS
                        )
                        if indicator_count / window_size < 0.15:
                            continue  # insufficient indicator density
                positions.add(i)

    # Second pass: detect Hebrew consonant clusters with form markers
    # (e.g., aSvRr = aleph+S+shin+R+resh) that lack diacritic/vowel
    # indicators.  Seed a position when a form-marker byte sits between
    # two Hebrew consonant bytes (or another form marker+consonant).
    _HEBREW_FORM_MARKERS = frozenset(
        {
            0x44,
            0x46,
            0x4A,
            0x51,
            0x52,
            0x53,
            0x54,
            0x56,
            0x58,
            0x5A,
        }
    )
    for i in range(len(data)):
        if data[i] in _HEBREW_FORM_MARKERS:
            # Form marker between two consonants (e.g., a S v in aSvRr)
            if i > 0 and i + 1 < len(data):
                prev_is_cons = data[i - 1] in hebrew_consonant_bytes
                next_is_cons = data[i + 1] in hebrew_consonant_bytes
                if prev_is_cons and next_is_cons:
                    # Seed the position at the form marker
                    positions.add(i)

    if not positions:
        return []

    # Merge nearby positions into spans, splitting on English markers.
    # Hebrew uses specific uppercase letters (defined above in _HEBREW_UPPERCASE)
    # These should NOT trigger English detection when adjacent to Hebrew.
    GAP = 25
    spans = []
    sorted_pos = sorted(positions)
    span_start = sorted_pos[0]
    prev_pos = sorted_pos[0]

    for pos in sorted_pos[1:]:
        gap_size = pos - prev_pos - 1
        if gap_size > GAP:
            gap_bytes = data[prev_pos + 1 : pos]
            has_english = any(
                (b in range(0x41, 0x5B) or b in range(0x30, 0x3A))
                and b not in _HEBREW_UPPERCASE
                for b in gap_bytes
            )
            if has_english:
                spans.append((span_start, prev_pos + 1))
                span_start = pos
        prev_pos = pos
    spans.append((span_start, prev_pos + 1))

    # Extend each span outward through adjacent Hebrew bytes
    extended = []
    for start, end in spans:
        ns = start
        while ns > 0 and data[ns - 1] in hebrew_letter_bytes:
            ns -= 1
            if start - ns > 40:
                break
        ne = end
        while ne < len(data) and data[ne] in hebrew_letter_bytes:
            ne += 1
            if ne - start > 80:
                break
        extended.append((ns, ne))

    # Merge overlapping
    if not extended:
        return []
    merged = [extended[0]]
    for sp in extended[1:]:
        if sp[0] < merged[-1][1]:
            merged[-1] = (merged[-1][0], max(merged[-1][1], sp[1]))
        else:
            merged.append(sp)
    return merged


def decode_hebrew(data: bytes, no_greek: bool = False, etcbc: bool = False) -> str:
    """Decode bytes from the legacy Accordance Hebrew encoding to Unicode.

    ASCII letters that map to Hebrew consonants are decoded as Hebrew.
    8-bit bytes serve as vowel-point / dagesh modifiers on the preceding
    consonant.  Non-Hebrew ASCII passes through as Latin-1.
    Stray C1 bytes (0x80-0x9F) in Latin-1 regions are replaced with
    space to prevent control characters in output.

    When no_greek=True, Hebrew span detection is skipped entirely
    (useful for modules known to contain no Hebrew text).
    """
    spans = [] if no_greek else _find_hebrew_spans(data)
    result = []
    pos = 0

    for start, end in spans:
        if pos < start:
            latin = data[pos:start]
            latin = _pre_convert_macroman(latin)
            latin = _sanitize_latin1(latin)
            result.append(latin.decode("latin-1", errors="replace"))

        hebrew_bytes = data[start:end]
        result.append(_decode_hebrew_raw(hebrew_bytes, etcbc=etcbc))
        pos = end

    if pos < len(data):
        latin = data[pos:]
        latin = _pre_convert_macroman(latin)
        latin = _sanitize_latin1(latin)
        result.append(latin.decode("latin-1", errors="replace"))

    text = "".join(result).replace("\xa7", "\u03c2")
    text = text.replace("{", "").replace(
        "}", ""
    )  # Accordance Hebrew delimiters (0x7B/0x7D)
    return _postprocess_decoded(text)


def decode_hebrew_always(data: bytes) -> str:
    """Decode bytes as pure Accordance Hebrew encoding (no Latin fallback)."""
    return _decode_hebrew_raw(data)


def _decode_hebrew_raw(data: bytes, etcbc: bool = False) -> str:
    """Decode a byte sequence as Accordance Hebrew encoding.

    ASCII lowercase letters → Hebrew consonants.
    ASCII uppercase A/E/I/O/U → vowel markers on preceding consonant.
    ASCII uppercase K/M/N/P/C → final forms.
    'v' is context-dependent: shin (שׁ) at word start, sheva (vowel) between
    consonants.  Same for 'w' (holem/vav).
    8-bit bytes → vowel-point / dagesh modifiers on preceding character.
    """
    result: list[str] = []
    i = 0
    n = len(data)

    def _is_consonant_byte(b: int) -> bool:
        """Return True if byte b is a Hebrew consonant (not vowel)."""
        heb = _HEBREW_ASCII.get(b)
        if heb is None:
            return False
        # Exclude vowel characters from being counted as consonants
        if b in _HEBREW_VOWEL_CHARS:
            return False
        if b in (0x76, 0x77):  # v, w are ambiguous; don't count as consonant here
            return False
        return True

    def _next_is_consonant(j: int) -> bool:
        """Return True if byte at position j is a Hebrew consonant."""
        if j >= n:
            return False
        b = data[j]
        if b >= 0x80:
            return False
        if b in _HEBREW_VOWEL_CHARS:
            return False
        return b in _HEBREW_ASCII

    def _prev_is_consonant() -> bool:
        """Return True if the last output char came from a consonant byte."""
        if not result:
            return False
        last = result[-1]
        return "\u05d0" <= last[-1] <= "\u05ea"

    while i < n:
        b = data[i]

        # 8-bit range: diacritic modifier on previous char
        if 0x80 <= b <= 0xFF:
            if result:
                prev = result[-1]
                point = _HEBREW_POINTS.get(b)
                if point is not None:
                    result[-1] = prev + point
            i += 1
            continue

        # Uppercase vowel markers: A, E, I, O, U
        vowel = _HEBREW_VOWEL_CHARS.get(b)
        if vowel is not None:
            if result:
                prev = result[-1]
                if "\u05d0" <= prev[-1] <= "\u05ea" if prev else False:
                    result[-1] = prev + vowel
                else:
                    result.append(vowel)
            i += 1
            continue

        # Context-dependent: v and w
        if b == 0x76:  # v — shin or sheva
            if _prev_is_consonant() and _next_is_consonant(i + 1):
                # Between two consonants: vowel marker (sheva)
                if result:
                    prev = result[-1]
                    if "\u05d0" <= prev[-1] <= "\u05ea":
                        result[-1] = prev + "\u05b0"  # sheva
                i += 1
                continue
            # Otherwise: shin consonant
            result.append("\u05e9")  # שׁ
            i += 1
            continue

        if b == 0x77:  # w — vav or holem marker
            if _prev_is_consonant() and _next_is_consonant(i + 1):
                if result:
                    prev = result[-1]
                    if "\u05d0" <= prev[-1] <= "\u05ea":
                        result[-1] = prev + "\u05b9"  # holem
                i += 1
                continue
            result.append("\u05d5")  # ו  vav
            i += 1
            continue

        # Uppercase form/variant markers (D, F, J, Q, R, S, T, V, X, Z)
        # These appear between Hebrew consonants and should be skipped.
        if b in _HEBREW_FORM_MARKERS:
            i += 1
            continue

        # Hebrew consonant (lowercase or uppercase)
        heb = _HEBREW_ASCII.get(b)
        if heb is not None:
            result.append(heb)
            i += 1
            continue

        # Brace delimiters (0x7B/0x7D): Accordance internal formatting
        # between Hebrew headwords and their transliterations.
        if b == 0x7B or b == 0x7D:
            i += 1
            continue

        # Pass-through ASCII
        if 0x20 <= b < 0x7F:
            result.append(chr(b))
        elif b == 0x0D:
            result.append(" ")
        i += 1

    result_str = "".join(result)
    if etcbc:
        # For full text: strip all semicolons (ETCBC morphology separators)
        # and ETCBC tag letters (BGHLWY), NOT just the first fragment.
        result_str = result_str.replace(";", "").translate(
            str.maketrans("", "", "BGHLWY")
        )
    return result_str


def normalize_hebrew_headword(headword: str) -> str:
    """Strip diacritics and marker letters for fuzzy Hebrew headword matching.

    Removes combining marks (niqqud) and non-Hebrew marker characters
    (uppercase Latin vowel markers like D, R, T, Z, etc.) that appear
    in the Accordance Hebrew encoding.
    """
    nfd = unicodedata.normalize("NFD", headword)
    base = "".join(c for c in nfd if not unicodedata.combining(c))
    # Remove uppercase marker letters that aren't Hebrew consonants
    # (A, E, I, O, U are vowel markers; D, F, Q, R, T, X, Z are form markers)
    _marker_letters = set("AEIOUDFQRTXZ")
    result = []
    for c in base:
        if c in _marker_letters:
            continue
        if c == ";":
            continue
        result.append(c)
    return "".join(result)


# ═══════════════════════════════════════════════════════════════════════
# Apparatus sigla encoding (Vector B)
# ═══════════════════════════════════════════════════════════════════════
#
# NA28 / LXX apparatus modules use a custom 8-bit encoding ("Vector B")
# where certain high bytes represent critical-apparatus sigla instead of
# their standard Latin-1 or MacRoman glyphs.  This map translates those
# byte positions to the corresponding Unicode sigla.
#
# Bytes NOT in this map pass through unchanged (ASCII or Latin-1).
# The Greek decoding path is NOT touched — callers apply this after
# Greek has already been decoded (or when decoding Latin apparatus text).

_APPARATUS_SIGLA_MAP: dict[int, str] = {
    # ── Manuscript / witness sigla ──
    0x8C: "\u2135",  # ℵ  Codex Sinaiticus (aleph)
    0xE5: "\U0001d510",  # 𝔐  Majority / Byzantine text (fraktur M)
    0xF2: "\u2113",  # ℓ  Lectionary (script l)
    0xEC: "f\u00b9",  # f¹  Manuscript family 1
    # ── Punctuation / critical marks ──
    0xA6: "\u00a6",  # ¦  broken bar (apparatus separator)
    0xB8: "\u00b8",  # ¸  cedilla (apparatus punctuation)
    0xC8: "\u2016",  # ‖  double vertical line (parallel passages)
    0xD0: "\u2013",  # –  en dash
    # ── Additional apparatus sigla (Vector B extended) ──
    0xE0: "\U0001d513",  # 𝔓  Papyrus (fraktur P)
    0xC9: "\u2e00",  # ⸀  critical sign (alternative reading)
    0xCA: "\u2e01",  # ⸁  critical sign
    0xCB: "\u2e02",  # ⸂  critical sign (addition)
    0xCC: "\u2e03",  # ⸃  critical sign (omission)
    0xCD: "\u2e04",  # ⸄  critical sign (transposition)
    0xCE: "\u2e05",  # ⸅  critical sign (substitution)
    0xCF: "\u2731",  # ✱  asterisk (original-hand corrector)
    0xE6: "\u2118",  # ℘  script P (Weierstrass p, alternative papyrus)
    0xD7: "\u00d7",  # ×  multiplication sign (attestation separator)
    0xE1: "\u2e0a",  # ⸊  critical sign
    0xE2: "\u2e0b",  # ⸋  critical sign
}

# Bytes that must survive C1 sanitization in apparatus text because they
# encode apparatus sigla (e.g. 0x8C = ℵ).  Non-sigla C1 bytes (0x80-0x9F)
# still get replaced with space.
_APPARATUS_SIGLA_C1_BYTES = frozenset(
    k for k in _APPARATUS_SIGLA_MAP if 0x80 <= k <= 0x9F
)


def _decode_apparatus(text: str) -> str:
    """Replace high bytes in Latin-1-decoded apparatus text with sigla.

    Iterates over the string, replacing any character whose Latin-1 ordinal
    appears in ``_APPARATUS_SIGLA_MAP`` with the corresponding Unicode siglum.
    ASCII bytes (0x00–0x7F) and unmapped high bytes pass through unchanged.

    Also applies a lightweight Beta Code pass: contiguous runs of 3+ ASCII
    letters where every character maps to Greek via ``_GREEK_ASCII`` are
    converted to Greek Unicode (e.g. ``hggikan`` → ἤγγικαν).

    Byte 0xA7 (§) within or adjacent to Beta Code runs is converted to
    final sigma (ς, U+03C2) — it is the Accordance 8-bit encoding for
    word-final sigma, never a section sign in apparatus Greek text.
    """
    result: list[str] = []
    i = 0
    n = len(text)
    while i < n:
        ch = text[i]
        b = ord(ch)

        # ── Apparatus sigla replacement ──
        if b >= 0x80 and b in _APPARATUS_SIGLA_MAP:
            result.append(_APPARATUS_SIGLA_MAP[b])
            i += 1
            continue

        # ── Beta Code detection: ASCII letter run → Greek ──
        if (0x41 <= b <= 0x5A) or (0x61 <= b <= 0x7A):
            run_start = i
            while i < n:
                cb = ord(text[i])
                if not ((0x41 <= cb <= 0x5A) or (0x61 <= cb <= 0x7A)):
                    break
                i += 1
            # Consume trailing § (0xA7) as final sigma — Accordance 8-bit
            # encoding uses 0xA7 for ς in Greek text, never as a section sign.
            _trailing_sigma = False
            if i < n and ord(text[i]) == 0xA7:
                _trailing_sigma = True
                i += 1
            run = text[run_start:i]
            # Only the ASCII-letter portion counts toward the 3-char minimum;
            # § is not a letter and must not push short Latin fragments into
            # false Greek conversion (e.g. German "gro§" → γρος).
            _letter_run = run[:-1] if _trailing_sigma else run
            # Convert only runs of 3+ letters where every letter maps to Greek
            # and the run is not a known Latin apparatus abbreviation.
            # Threshold is 3 (not 4) so short Greek readings like kai (= καί)
            # in apparatus text are captured.
            _LATIN_ABBREVS = frozenset(
                {
                    "txt",
                    "vgmss",
                    "vgms",
                    "vgs",
                    "vgst",
                    "lat",
                    "syr",
                    "syh",
                    "syp",
                    "cop",
                    "bopt",
                    "ita",
                    "itb",
                    "itc",
                }
            )
            if len(_letter_run) >= 3 and _letter_run.lower() not in _LATIN_ABBREVS:
                greek_run: list[str] = []
                all_convertible = True
                for rc in run:
                    if ord(rc) == 0xA7:
                        greek_run.append("\u03c2")  # final sigma
                        continue
                    gc = _GREEK_ASCII.get(ord(rc))
                    if gc is None:
                        all_convertible = False
                        break
                    greek_run.append(gc)
                if all_convertible:
                    result.extend(greek_run)
                else:
                    result.append(run)
            else:
                result.append(run)
            continue

        # ── Standalone § (0xA7) after Greek → final sigma ──
        # Convert § to ς when it immediately follows already-decoded Greek
        # Unicode (the preceding Beta Code run was converted earlier).
        # The post-pass regex below handles the opposite case (§ before
        # a Greek run that hasn't been scanned yet).
        if b == 0xA7:
            _prev_greek = len(result) >= 1 and (
                "\u0370" <= result[-1] <= "\u03ff" or "\u1f00" <= result[-1] <= "\u1fff"
            )
            if _prev_greek:
                result.append("\u03c2")
                i += 1
                continue

        result.append(ch)
        i += 1

    text_out = "".join(result)

    # Post-pass: convert any § still adjacent to Greek Unicode to final sigma.
    # This catches edge cases where § sits between two Greek characters but
    # the Beta Code run on one side was too short to trigger conversion.
    import re

    text_out = re.sub(
        r"(?<=[\u0370-\u03FF\u1F00-\u1FFF])\xa7",
        "\u03c2",
        text_out,
    )
    text_out = re.sub(
        r"\xa7(?=[\u0370-\u03FF\u1F00-\u1FFF])",
        "\u03c2",
        text_out,
    )

    return text_out


def decode_apparatus(data: bytes) -> str:
    """Decode bytes from Vector B apparatus encoding to Unicode.

    Unlike ``decode()``, this treats high bytes as apparatus sigla rather
    than MacRoman or Greek diacritics:

    1. C1 control bytes (0x80-0x9F) that are NOT recognised apparatus sigla
       are replaced with space to prevent control characters in output.
    2. The byte sequence is decoded as Latin-1.
    3. ``_decode_apparatus`` replaces sigla bytes with Unicode equivalents
       and applies light Beta Code conversion for ASCII Greek runs.
    4. ``_postprocess_decoded`` cleans up remaining artifacts.

    Use this for NA28, LXX, and other text-critical apparatus modules
    whose content uses the Vector B encoding.
    """
    result = bytearray(data)
    for i in range(len(result)):
        b = result[i]
        if 0x80 <= b <= 0x9F and b not in _APPARATUS_SIGLA_C1_BYTES:
            result[i] = 0x20
    text = bytes(result).decode("latin-1", errors="replace")
    text = _decode_apparatus(text)
    return _postprocess_decoded(text)
