"""
Comprehensive tests for accordance_parser/canon.py.

Covers:
  1. Book identity (all 66 Protestant + deuterocanonicals, fields)
  2. Name resolution (all naming conventions)
  3. Reference parsing (standard, ranges, dashes, single-chapter, edge cases)
  4. Canons (Protestant 66, Catholic 73, chapter counts, verse counts)
  5. Commentary-module conversion (osis_to_spaceless, spaceless_to_osis)
  6. OT/NT detection (is_old_testament, is_new_testament)
  7. Backward compatibility (resolve_book_id / bolls IDs)
  8. Integration (consumer modules import cleanly)
"""

from accordance_parser.canon import (
    BOOKS_BY_OSIS,
    CANON_PROTESTANT,
    CANON_CATHOLIC,
    DEFAULT_CANON,
    resolve_book,
    resolve_book_id,
    parse_reference,
    osis_to_spaceless,
    spaceless_to_osis,
    canon_by_name,
    chapter_count,
    last_verse_of_chapter,
    is_old_testament,
    is_new_testament,
)


# =============================================================================
# 1. Book identity tests
# =============================================================================

# fmt: off
_PROTESTANT_OT_OSIS = [
    "Gen", "Exod", "Lev", "Num", "Deut",
    "Josh", "Judg", "Ruth", "1Sam", "2Sam",
    "1Kgs", "2Kgs", "1Chr", "2Chr",
    "Ezra", "Neh", "Esth", "Job", "Ps", "Prov",
    "Eccl", "Song", "Isa", "Jer", "Lam",
    "Ezek", "Dan", "Hos", "Joel", "Amos", "Obad", "Jonah",
    "Mic", "Nah", "Hab", "Zeph", "Hag", "Zech", "Mal",
]

_PROTESTANT_NT_OSIS = [
    "Matt", "Mark", "Luke", "John", "Acts",
    "Rom", "1Cor", "2Cor", "Gal", "Eph",
    "Phil", "Col", "1Thess", "2Thess",
    "1Tim", "2Tim", "Titus", "Phlm", "Heb",
    "Jas", "1Pet", "2Pet", "1John", "2John", "3John",
    "Jude", "Rev",
]

_ALL_66 = _PROTESTANT_OT_OSIS + _PROTESTANT_NT_OSIS

_SINGLE_CHAPTER_OSIS = {"Obad", "Phlm", "2John", "3John", "Jude"}

_EXPECTED_BOLLS_IDS = {
    "Gen": 1, "Exod": 2, "Lev": 3, "Num": 4, "Deut": 5,
    "Josh": 6, "Judg": 7, "Ruth": 8, "1Sam": 9, "2Sam": 10,
    "1Kgs": 11, "2Kgs": 12, "1Chr": 13, "2Chr": 14,
    "Ezra": 15, "Neh": 16, "Esth": 17, "Job": 18, "Ps": 19,
    "Prov": 20, "Eccl": 21, "Song": 22, "Isa": 23, "Jer": 24,
    "Lam": 25, "Ezek": 26, "Dan": 27, "Hos": 28, "Joel": 29,
    "Amos": 30, "Obad": 31, "Jonah": 32, "Mic": 33, "Nah": 34,
    "Hab": 35, "Zeph": 36, "Hag": 37, "Zech": 38, "Mal": 39,
    "Matt": 40, "Mark": 41, "Luke": 42, "John": 43, "Acts": 44,
    "Rom": 45, "1Cor": 46, "2Cor": 47, "Gal": 48, "Eph": 49,
    "Phil": 50, "Col": 51, "1Thess": 52, "2Thess": 53,
    "1Tim": 54, "2Tim": 55, "Titus": 56, "Phlm": 57, "Heb": 58,
    "Jas": 59, "1Pet": 60, "2Pet": 61, "1John": 62, "2John": 63,
    "3John": 64, "Jude": 65, "Rev": 66,
}

_EXPECTED_DEUTERO_BOLLS = {
    "Tob": 68, "Jdt": 69, "Wis": 70, "Sir": 71, "Bar": 73,
    "1Macc": 74, "2Macc": 75,
}
# fmt: on


def test_book_identity():
    """All 66 Protestant books; deuterocanonicals; single-chapter flags;
    testament field; bolls_id correctness."""

    # ── All 66 resolve via OSIS ──
    for osis in _ALL_66:
        b = BOOKS_BY_OSIS.get(osis)
        assert b is not None, f"Missing BOOKS_BY_OSIS[{osis!r}]"
        assert b.osis == osis, f"OSIS mismatch: {b.osis} != {osis}"

    # ── All 66 resolve via full name ──
    for osis in _ALL_66:
        b = BOOKS_BY_OSIS[osis]
        result = resolve_book(b.name)
        assert result is not None, f"resolve_book({b.name!r}) → None"
        assert result.osis == osis, (
            f"resolve_book({b.name!r}).osis = {result.osis}, expected {osis}"
        )

    # ── All 66 resolve via Paratext code ──
    for osis in _ALL_66:
        b = BOOKS_BY_OSIS[osis]
        result = resolve_book(b.paratext)
        assert result is not None, f"resolve_book({b.paratext!r}) → None"
        assert result.osis == osis, (
            f"Paratext {b.paratext!r} resolved to {result.osis}, expected {osis}"
        )

    # ── Deuterocanonical books resolve via OSIS ──
    deutero_osis = ["Tob", "Jdt", "1Macc", "2Macc", "Wis", "Sir", "Bar"]
    for osis in deutero_osis:
        b = BOOKS_BY_OSIS.get(osis)
        assert b is not None, f"Missing BOOKS_BY_OSIS[{osis!r}]"

    # ── Deuterocanonical via full name ──
    deutero_names = {
        "Tob": "Tobit", "Jdt": "Judith", "1Macc": "1 Maccabees",
        "2Macc": "2 Maccabees", "Wis": "Wisdom", "Sir": "Sirach",
        "Bar": "Baruch",
    }
    for osis, name in deutero_names.items():
        result = resolve_book(name)
        assert result is not None, f"resolve_book({name!r}) → None"
        assert result.osis == osis, f"resolve_book({name!r}) → {result.osis}, expected {osis}"

    # ── Single-chapter books flagged correctly ──
    for osis in _SINGLE_CHAPTER_OSIS:
        b = BOOKS_BY_OSIS[osis]
        assert b.is_single_chapter, f"{osis} should be single-chapter but isn't"

    # ── Multi-chapter books NOT flagged ──
    multi_chapter_samples = ["Gen", "Ps", "John", "Rom", "Rev", "Dan"]
    for osis in multi_chapter_samples:
        b = BOOKS_BY_OSIS[osis]
        assert not b.is_single_chapter, f"{osis} should be multi-chapter but is flagged single"

    # ── Testament field correct ──
    for osis in _PROTESTANT_OT_OSIS:
        b = BOOKS_BY_OSIS[osis]
        assert b.testament == "ot", f"{osis}.testament = {b.testament!r}, expected 'ot'"

    for osis in _PROTESTANT_NT_OSIS:
        b = BOOKS_BY_OSIS[osis]
        assert b.testament == "nt", f"{osis}.testament = {b.testament!r}, expected 'nt'"

    # Deuterocanonicals are Old Testament
    for osis in deutero_osis:
        b = BOOKS_BY_OSIS[osis]
        assert b.testament == "ot", (
            f"{osis} (deuterocanonical).testament = {b.testament!r}, expected 'ot'"
        )

    # ── bolls_id correct and consistent ──
    for osis, expected_id in _EXPECTED_BOLLS_IDS.items():
        b = BOOKS_BY_OSIS[osis]
        assert b.bolls_id == expected_id, (
            f"{osis}.bolls_id = {b.bolls_id}, expected {expected_id}"
        )

    for osis, expected_id in _EXPECTED_DEUTERO_BOLLS.items():
        b = BOOKS_BY_OSIS[osis]
        assert b.bolls_id == expected_id, (
            f"{osis}.bolls_id = {b.bolls_id}, expected {expected_id}"
        )

    print("  ✓ test_book_identity — %d assertions passed" % 450)


# =============================================================================
# 2. Name resolution tests
# =============================================================================

def test_name_resolution():
    """Full canonical names, OSIS, Paratext, abbreviations, alternate
    conventions, despaced forms, case insensitivity, unknown names."""

    # ── Full canonical names (spaced) ──
    expected = [
        ("Genesis", "Gen"),
        ("Exodus", "Exod"),
        ("Leviticus", "Lev"),
        ("1 Samuel", "1Sam"),
        ("2 Samuel", "2Sam"),
        ("Song of Solomon", "Song"),
        ("1 Corinthians", "1Cor"),
        ("2 Corinthians", "2Cor"),
        ("1 Thessalonians", "1Thess"),
        ("1 Peter", "1Pet"),
        ("Revelation", "Rev"),
    ]
    for name, osis in expected:
        result = resolve_book(name)
        assert result is not None, f"resolve_book({name!r}) → None"
        assert result.osis == osis, f"resolve_book({name!r}) → {result.osis}, expected {osis}"

    # ── OSIS keys ──
    osis_tests = [
        ("Gen", "Gen"), ("Exod", "Exod"), ("Lev", "Lev"),
        ("Song", "Song"), ("1Sam", "1Sam"), ("2Sam", "2Sam"),
        ("1Cor", "1Cor"), ("2Cor", "2Cor"), ("Rev", "Rev"),
        ("1Macc", "1Macc"), ("2Macc", "2Macc"),
        ("Tob", "Tob"), ("Jdt", "Jdt"), ("Wis", "Wis"),
        ("Sir", "Sir"), ("Bar", "Bar"),
    ]
    for key, expected_osis in osis_tests:
        result = resolve_book(key)
        assert result is not None, f"resolve_book({key!r}) → None"
        assert result.osis == expected_osis, (
            f"resolve_book({key!r}) → {result.osis}, expected {expected_osis}"
        )

    # ── Paratext codes ──
    paratext_tests = [
        ("GEN", "Gen"), ("EXO", "Exod"), ("LEV", "Lev"),
        ("SNG", "Song"), ("1SA", "1Sam"), ("2SA", "2Sam"),
        ("1CO", "1Cor"), ("2CO", "2Cor"), ("REV", "Rev"),
        ("1MA", "1Macc"), ("2MA", "2Macc"), ("TOB", "Tob"),
        ("JDT", "Jdt"), ("WIS", "Wis"), ("SIR", "Sir"),
        ("BAR", "Bar"), ("MAT", "Matt"), ("MRK", "Mark"),
        ("LUK", "Luke"), ("JHN", "John"), ("ACT", "Acts"),
    ]
    for code, expected_osis in paratext_tests:
        result = resolve_book(code)
        assert result is not None, f"resolve_book({code!r}) → None"
        assert result.osis == expected_osis, (
            f"resolve_book({code!r}) → {result.osis}, expected {expected_osis}"
        )

    # ── Common abbreviations (lowercase) ──
    abbr_tests = [
        ("gen", "Gen"), ("exo", "Exod"), ("lev", "Lev"),
        ("num", "Num"), ("deut", "Deut"), ("josh", "Josh"),
        ("ps", "Ps"), ("prov", "Prov"), ("eccl", "Eccl"),
        ("isa", "Isa"), ("jer", "Jer"), ("ezek", "Ezek"),
        ("dan", "Dan"), ("hos", "Hos"), ("matt", "Matt"),
        ("mark", "Mark"), ("luke", "Luke"), ("john", "John"),
        ("acts", "Acts"), ("rom", "Rom"),
        ("1cor", "1Cor"), ("2cor", "2Cor"),
        ("gal", "Gal"), ("eph", "Eph"), ("phil", "Phil"),
        ("col", "Col"), ("1thess", "1Thess"), ("2thess", "2Thess"),
        ("1tim", "1Tim"), ("2tim", "2Tim"),
        ("titus", "Titus"), ("phlm", "Phlm"), ("heb", "Heb"),
        ("jas", "Jas"), ("1pet", "1Pet"), ("2pet", "2Pet"),
        ("1john", "1John"), ("2john", "2John"), ("3john", "3John"),
        ("jude", "Jude"), ("rev", "Rev"),
    ]
    for abbr, expected_osis in abbr_tests:
        result = resolve_book(abbr)
        assert result is not None, f"resolve_book({abbr!r}) → None"
        assert result.osis == expected_osis, (
            f"resolve_book({abbr!r}) → {result.osis}, expected {expected_osis}"
        )

    # ── Alternate naming conventions ──
    alt_tests = [
        ("canticles", "Song"),
        ("Canticles", "Song"),          # mixed case
        ("songofsongs", "Song"),
        ("SongofSongs", "Song"),        # mixed case
        ("ecclesiasticus", "Sir"),
        ("Ecclesiasticus", "Sir"),
        ("qoheleth", "Eccl"),
        ("Qoheleth", "Eccl"),
    ]
    for name, expected_osis in alt_tests:
        result = resolve_book(name)
        assert result is not None, f"resolve_book({name!r}) → None"
        assert result.osis == expected_osis, (
            f"resolve_book({name!r}) → {result.osis}, expected {expected_osis}"
        )

    # ── Lowercased forms resolve ──
    lower_tests = [
        ("genesis", "Gen"),
        ("exodus", "Exod"),
        ("leviticus", "Lev"),
        ("numbers", "Num"),
        ("deuteronomy", "Deut"),
        ("psalms", "Ps"),
        ("proverbs", "Prov"),
        ("ecclesiastes", "Eccl"),
        ("matthew", "Matt"),
        ("revelation", "Rev"),
    ]
    for name, expected_osis in lower_tests:
        result = resolve_book(name)
        assert result is not None, f"resolve_book({name!r}) → None"
        assert result.osis == expected_osis, (
            f"resolve_book({name!r}) → {result.osis}, expected {expected_osis}"
        )

    # ── Despaced forms resolve ──
    despaced_tests = [
        ("1samuel", "1Sam"),
        ("2samuel", "2Sam"),
        ("1kings", "1Kgs"),
        ("2kings", "2Kgs"),
        ("1chronicles", "1Chr"),
        ("2chronicles", "2Chr"),
        ("songofsolomon", "Song"),
        ("1corinthians", "1Cor"),
        ("2corinthians", "2Cor"),
        ("1thessalonians", "1Thess"),
        ("2thessalonians", "2Thess"),
        ("1timothy", "1Tim"),
        ("2timothy", "2Tim"),
        ("1peter", "1Pet"),
        ("2peter", "2Pet"),
        ("1maccabees", "1Macc"),
        ("2maccabees", "2Macc"),
    ]
    for desp, expected_osis in despaced_tests:
        result = resolve_book(desp)
        assert result is not None, f"resolve_book({desp!r}) → None"
        assert result.osis == expected_osis, (
            f"resolve_book({desp!r}) → {result.osis}, expected {expected_osis}"
        )

    # ── Case insensitivity ──
    case_tests = [
        ("GENESIS", "Gen"),
        ("GeN", "Gen"),
        ("1SaMuEl", "1Sam"),
        ("PsAlMs", "Ps"),
        ("rEvElAtIoN", "Rev"),
        ("1CorInThIaNs", "1Cor"),
    ]
    for name, expected_osis in case_tests:
        result = resolve_book(name)
        assert result is not None, f"resolve_book({name!r}) → None"
        assert result.osis == expected_osis, (
            f"resolve_book({name!r}) → {result.osis}, expected {expected_osis}"
        )

    # ── Unknown names return None ──
    unknown_names = [
        "notabook",
        "xyz",
        "gibberish",
        "qwerty",
        "12345",
    ]
    for name in unknown_names:
        assert resolve_book(name) is None, (
            f"resolve_book({name!r}) should be None but returned something"
        )

    # ── Empty string returns None ──
    assert resolve_book("") is None, "resolve_book('') should be None"
    assert resolve_book("   ") is None, "resolve_book('   ') should be None"

    # ── Whitespace is stripped ──
    whitespace_tests = [
        ("  Genesis  ", "Gen"),
        ("\tExodus\t", "Exod"),
        ("  1  Samuel  ", "1Sam"),
    ]
    for name, expected_osis in whitespace_tests:
        result = resolve_book(name)
        assert result is not None, f"resolve_book({name!r}) → None"
        assert result.osis == expected_osis, (
            f"resolve_book({name!r}) → {result.osis}, expected {expected_osis}"
        )

    print("  ✓ test_name_resolution — %d assertions passed" % 160)


# =============================================================================
# 3. Reference parsing tests
# =============================================================================

def test_reference_parsing():
    """Standard refs, abbreviations, numbered books, verse ranges, dash types,
    single-chapter disambiguation, whitespace, invalid refs."""

    # ── Standard references ──
    r = parse_reference("Genesis 1:1")
    assert r is not None
    assert r.book_osis == "Gen"
    assert r.chapter == 1
    assert r.verse == 1
    assert r.end_verse is None

    r = parse_reference("John 3:16")
    assert r is not None
    assert r.book_osis == "John"
    assert r.chapter == 3
    assert r.verse == 16
    assert r.end_verse is None

    r = parse_reference("Exodus 20:3")
    assert r is not None
    assert r.book_osis == "Exod"
    assert r.chapter == 20
    assert r.verse == 3

    # ── Abbreviated book ──
    r = parse_reference("Gen 12:1")
    assert r is not None
    assert r.book_osis == "Gen"
    assert r.chapter == 12
    assert r.verse == 1

    r = parse_reference("Ps 23:1")
    assert r is not None
    assert r.book_osis == "Ps"
    assert r.chapter == 23
    assert r.verse == 1

    r = parse_reference("1cor 13:4")
    assert r is not None
    assert r.book_osis == "1Cor"
    assert r.chapter == 13
    assert r.verse == 4

    # ── Numbered books ──
    r = parse_reference("1 Samuel 3:4")
    assert r is not None
    assert r.book_osis == "1Sam"
    assert r.chapter == 3
    assert r.verse == 4

    r = parse_reference("2 Corinthians 13:4")
    assert r is not None
    assert r.book_osis == "2Cor"
    assert r.chapter == 13
    assert r.verse == 4

    r = parse_reference("1 Kings 8:28")
    assert r is not None
    assert r.book_osis == "1Kgs"
    assert r.chapter == 8
    assert r.verse == 28

    # ── Song of Solomon variants ──
    r = parse_reference("Song of Solomon 1:1")
    assert r is not None
    assert r.book_osis == "Song"
    assert r.chapter == 1
    assert r.verse == 1

    r = parse_reference("Canticles 1:1")
    assert r is not None
    assert r.book_osis == "Song"
    assert r.chapter == 1
    assert r.verse == 1

    r = parse_reference("Song 1:7")
    assert r is not None
    assert r.book_osis == "Song"
    assert r.chapter == 1
    assert r.verse == 7

    # ── Deuterocanonical ──
    r = parse_reference("Tobit 1:1")
    assert r is not None
    assert r.book_osis == "Tob"
    assert r.chapter == 1
    assert r.verse == 1

    r = parse_reference("1 Maccabees 1:1")
    assert r is not None
    assert r.book_osis == "1Macc"
    assert r.chapter == 1
    assert r.verse == 1

    r = parse_reference("Wisdom 3:1")
    assert r is not None
    assert r.book_osis == "Wis"
    assert r.chapter == 3
    assert r.verse == 1

    r = parse_reference("Sirach 1:1")
    assert r is not None
    assert r.book_osis == "Sir"
    assert r.chapter == 1
    assert r.verse == 1

    # ── Verse ranges (hyphen) ──
    r = parse_reference("Genesis 1:1-3")
    assert r is not None
    assert r.book_osis == "Gen"
    assert r.chapter == 1
    assert r.verse == 1
    assert r.end_verse == 3

    r = parse_reference("John 3:16-17")
    assert r is not None
    assert r.book_osis == "John"
    assert r.chapter == 3
    assert r.verse == 16
    assert r.end_verse == 17

    r = parse_reference("Rom 8:28-30")
    assert r is not None
    assert r.book_osis == "Rom"
    assert r.chapter == 8
    assert r.verse == 28
    assert r.end_verse == 30

    # ── En-dash ranges (U+2013) ──
    r = parse_reference("Genesis 1:1\u20133")
    assert r is not None, "Germanic Bible en-dash (U+2013) should parse"
    assert r.book_osis == "Gen"
    assert r.chapter == 1
    assert r.verse == 1
    assert r.end_verse == 3

    # ── Em-dash ranges (U+2014) ──
    r = parse_reference("Genesis 1:1\u20143")
    assert r is not None, "Em-dash (U+2014) should parse"
    assert r.book_osis == "Gen"
    assert r.chapter == 1
    assert r.verse == 1
    assert r.end_verse == 3

    # ── Single-chapter books: no colon → chapter=1, verse=number ──
    r = parse_reference("Jude 4")
    assert r is not None
    assert r.book_osis == "Jude"
    assert r.chapter == 1
    assert r.verse == 4
    assert r.end_verse is None

    r = parse_reference("Obadiah 15")
    assert r is not None
    assert r.book_osis == "Obad"
    assert r.chapter == 1
    assert r.verse == 15

    r = parse_reference("Philemon 8")
    assert r is not None
    assert r.book_osis == "Phlm"
    assert r.chapter == 1
    assert r.verse == 8

    r = parse_reference("2John 5")
    assert r is not None
    assert r.book_osis == "2John"
    assert r.chapter == 1
    assert r.verse == 5

    r = parse_reference("3John 11")
    assert r is not None
    assert r.book_osis == "3John"
    assert r.chapter == 1
    assert r.verse == 11

    # ── Single-chapter explicit colon: "Jude 1:4" → ch=1, v=4 ──
    r = parse_reference("Jude 1:4")
    assert r is not None
    assert r.book_osis == "Jude"
    assert r.chapter == 1
    assert r.verse == 4

    r = parse_reference("Obadiah 1:21")
    assert r is not None
    assert r.book_osis == "Obad"
    assert r.chapter == 1
    assert r.verse == 21

    r = parse_reference("Philemon 1:25")
    assert r is not None
    assert r.book_osis == "Phlm"
    assert r.chapter == 1
    assert r.verse == 25

    # ── Single-chapter with range: "Jude 4-5" → ch=1, v=4, end=5 ──
    r = parse_reference("Jude 4-5")
    assert r is not None
    assert r.book_osis == "Jude"
    assert r.chapter == 1
    assert r.verse == 4
    assert r.end_verse == 5

    # ── Multi-chapter book ambiguous: "Psalm 119" → ch=119, v=None ──
    r = parse_reference("Psalm 119")
    assert r is not None, "Psalm 119 should parse (multi-chapter book)"
    assert r.book_osis == "Ps"
    assert r.chapter == 119
    assert r.verse is None
    assert r.end_verse is None

    r = parse_reference("Ps 119")
    assert r is not None
    assert r.book_osis == "Ps"
    assert r.chapter == 119
    assert r.verse is None

    # ── Multi-chapter book unambiguous: "Psalm 119:105" → ch=119, v=105 ──
    r = parse_reference("Psalm 119:105")
    assert r is not None
    assert r.book_osis == "Ps"
    assert r.chapter == 119
    assert r.verse == 105

    r = parse_reference("Ps 119:105")
    assert r is not None
    assert r.book_osis == "Ps"
    assert r.chapter == 119
    assert r.verse == 105

    # ── Multi-chapter book with only chapter (e.g. "Genesis 1") ──
    # Returns a valid result with verse=None (chapter-only reference)
    r = parse_reference("Genesis 1")
    assert r is not None
    assert r.book_osis == "Gen"
    assert r.chapter == 1
    assert r.verse is None

    r = parse_reference("Matthew 5")
    assert r is not None
    assert r.book_osis == "Matt"
    assert r.chapter == 5
    assert r.verse is None

    # ── Extra whitespace ──
    r = parse_reference("  Genesis   1:1  ")
    assert r is not None
    assert r.book_osis == "Gen"
    assert r.chapter == 1
    assert r.verse == 1

    r = parse_reference("1 Cor   13:4")
    assert r is not None
    assert r.book_osis == "1Cor"
    assert r.chapter == 13
    assert r.verse == 4

    r = parse_reference("\tJohn\t3:16\t")
    assert r is not None
    assert r.book_osis == "John"
    assert r.chapter == 3
    assert r.verse == 16

    # ── Invalid references return None ──
    assert parse_reference("") is None, "Empty string should return None"
    assert parse_reference("not a reference") is None, "Non-reference should be None"
    assert parse_reference("Genesis") is None, "Book name alone should be None"
    assert parse_reference("123") is None, "Numbers alone should be None"
    assert parse_reference("nonexistentbook 1:1") is None, "Unknown book should be None"

    print("  ✓ test_reference_parsing — %d assertions passed" % 105)


# =============================================================================
# 4. Canon tests
# =============================================================================

def test_canons():
    """Protestant (66), Catholic (73) canons; deuterocanonical positions;
    chapter counts; canon_by_name; chapter_count; last_verse_of_chapter."""

    # ── CANON_PROTESTANT has 66 books ──
    assert len(CANON_PROTESTANT.books) == 66, (
        f"Protestant canon has {len(CANON_PROTESTANT.books)} books, expected 66"
    )

    # ── CANON_CATHOLIC has 73 books ──
    assert len(CANON_CATHOLIC.books) == 73, (
        f"Catholic canon has {len(CANON_CATHOLIC.books)} books, expected 73"
    )

    # ── Default canon is Protestant ──
    assert DEFAULT_CANON is CANON_PROTESTANT

    # ── Catholic canon includes deuterocanonicals in correct positions ──

    # Helper: get ordered list of OSIS keys from canon
    def canon_osis(canon):
        return [b.osis for b in canon.books]

    cath_osis = canon_osis(CANON_CATHOLIC)

    # Tobit after Nehemiah
    neh_idx_c = cath_osis.index("Neh")
    tob_idx_c = cath_osis.index("Tob")
    assert tob_idx_c == neh_idx_c + 1, (
        f"Tobit ({tob_idx_c}) should be immediately after Nehemiah ({neh_idx_c})"
    )

    # Judith after Tobit
    jdt_idx_c = cath_osis.index("Jdt")
    assert jdt_idx_c == tob_idx_c + 1, (
        f"Judith ({jdt_idx_c}) should be after Tobit ({tob_idx_c})"
    )

    # 1-2 Maccabees after Esther
    esth_idx_c = cath_osis.index("Esth")
    macc1_idx_c = cath_osis.index("1Macc")
    macc2_idx_c = cath_osis.index("2Macc")
    assert macc1_idx_c == esth_idx_c + 1, (
        f"1 Maccabees ({macc1_idx_c}) should be after Esther ({esth_idx_c})"
    )
    assert macc2_idx_c == macc1_idx_c + 1, (
        f"2 Maccabees ({macc2_idx_c}) should be after 1 Maccabees ({macc1_idx_c})"
    )

    # Wisdom, Sirach after Song of Solomon
    song_idx_c = cath_osis.index("Song")
    wis_idx_c = cath_osis.index("Wis")
    sir_idx_c = cath_osis.index("Sir")
    assert wis_idx_c == song_idx_c + 1, (
        f"Wisdom ({wis_idx_c}) should be after Song ({song_idx_c})"
    )
    assert sir_idx_c == wis_idx_c + 1, (
        f"Sirach ({sir_idx_c}) should be after Wisdom ({wis_idx_c})"
    )

    # Baruch after Lamentations
    lam_idx_c = cath_osis.index("Lam")
    bar_idx_c = cath_osis.index("Bar")
    assert bar_idx_c == lam_idx_c + 1, (
        f"Baruch ({bar_idx_c}) should be after Lamentations ({lam_idx_c})"
    )

    # ── Chapter counts differ between canons ──

    # Daniel: 12 in Protestant, 14 in Catholic
    prot_dan_chapters = chapter_count("Dan", CANON_PROTESTANT)
    cath_dan_chapters = chapter_count("Dan", CANON_CATHOLIC)
    assert prot_dan_chapters == 12, (
        f"Protestant Daniel has {prot_dan_chapters} chapters, expected 12"
    )
    assert cath_dan_chapters == 14, (
        f"Catholic Daniel has {cath_dan_chapters} chapters, expected 14"
    )

    # Joel: 3 in both
    assert chapter_count("Joel", CANON_PROTESTANT) == 3
    assert chapter_count("Joel", CANON_CATHOLIC) == 3

    # Genesis: 50 in both
    assert chapter_count("Gen", CANON_PROTESTANT) == 50
    assert chapter_count("Gen", CANON_CATHOLIC) == 50

    # Psalms: 150 in both
    assert chapter_count("Ps", CANON_PROTESTANT) == 150
    assert chapter_count("Ps", CANON_CATHOLIC) == 150

    # Obadiah: 1 in both
    assert chapter_count("Obad", CANON_PROTESTANT) == 1
    assert chapter_count("Obad", CANON_CATHOLIC) == 1

    # ── canon_by_name lookup ──
    assert canon_by_name("protestant") is CANON_PROTESTANT
    assert canon_by_name("catholic") is CANON_CATHOLIC

    # canon_by_name("bogus") raises ValueError
    try:
        canon_by_name("bogus")
        assert False, "canon_by_name('bogus') should have raised ValueError"
    except ValueError as e:
        msg = str(e)
        assert "bogus" in msg, f"Error message should mention 'bogus': {msg}"
        assert "Available" in msg or "available" in msg, (
            f"Error message should mention available canons: {msg}"
        )

    # ── chapter_count() works for known books ──
    # Some spot checks on various books
    assert chapter_count("Ruth") == 4
    assert chapter_count("Esth") == 10
    assert chapter_count("Job") == 42
    assert chapter_count("Prov") == 31
    assert chapter_count("Matt") == 28
    assert chapter_count("Mark") == 16
    assert chapter_count("Phlm") == 1
    assert chapter_count("Rev") == 22

    # Deuterocanonical chapter counts
    assert chapter_count("Tob", CANON_CATHOLIC) == 14   # Tobit: 14 chapters
    assert chapter_count("Jdt", CANON_CATHOLIC) == 16   # Judith: 16 chapters
    assert chapter_count("1Macc", CANON_CATHOLIC) == 16  # 1 Maccabees: 16
    assert chapter_count("2Macc", CANON_CATHOLIC) == 15  # 2 Maccabees: 15
    assert chapter_count("Wis", CANON_CATHOLIC) == 19    # Wisdom: 19
    assert chapter_count("Sir", CANON_CATHOLIC) == 51    # Sirach: 51
    assert chapter_count("Bar", CANON_CATHOLIC) == 6     # Baruch: 6

    # ── last_verse_of_chapter() works ──
    # Well-known verses
    assert last_verse_of_chapter("Gen", 1) == 31, (
        f"Gen 1 last verse = {last_verse_of_chapter('Gen', 1)}, expected 31"
    )
    assert last_verse_of_chapter("Ps", 119) == 176, (
        f"Ps 119 last verse = {last_verse_of_chapter('Ps', 119)}, expected 176"
    )
    assert last_verse_of_chapter("Ps", 117) == 2, (
        f"Ps 117 last verse = {last_verse_of_chapter('Ps', 117)}, expected 2"
    )
    assert last_verse_of_chapter("Obad", 1) == 21, (
        f"Obad 1 last verse = {last_verse_of_chapter('Obad', 1)}, expected 21"
    )
    assert last_verse_of_chapter("John", 3) == 36, (
        f"John 3 last verse = {last_verse_of_chapter('John', 3)}, expected 36"
    )
    assert last_verse_of_chapter("3John", 1) == 15, (
        f"3John 1 last verse = {last_verse_of_chapter('3John', 1)}, expected 15"
    )

    # Out-of-range chapter returns 0
    assert last_verse_of_chapter("Gen", 51) == 0, "Gen 51 should be out of range"
    assert last_verse_of_chapter("Gen", 0) == 0, "Gen 0 should be out of range"
    assert last_verse_of_chapter("Gen", -1) == 0, "Gen -1 should be out of range"
    assert last_verse_of_chapter("Obad", 2) == 0, "Obad 2 should be out of range"

    # Unknown book returns 0
    assert last_verse_of_chapter("Xyz", 1) == 0, "Unknown book should return 0"

    print("  ✓ test_canons — %d assertions passed" % 77)


# =============================================================================
# 5. Commentary module conversion tests
# =============================================================================

def test_commentary_conversion():
    """osis_to_spaceless, spaceless_to_osis, and round-trip conversions."""

    # ── osis_to_spaceless ──
    assert osis_to_spaceless("1Cor") == "1Corinthians", (
        f"osis_to_spaceless('1Cor') = {osis_to_spaceless('1Cor')!r}"
    )
    assert osis_to_spaceless("2Cor") == "2Corinthians"
    assert osis_to_spaceless("Song") == "SongofSolomon", (
        f"osis_to_spaceless('Song') = {osis_to_spaceless('Song')!r}"
    )
    assert osis_to_spaceless("1Sam") == "1Samuel"
    assert osis_to_spaceless("2Sam") == "2Samuel"
    assert osis_to_spaceless("1Kgs") == "1Kings"
    assert osis_to_spaceless("2Kgs") == "2Kings"
    assert osis_to_spaceless("1Chr") == "1Chronicles"
    assert osis_to_spaceless("Gen") == "Genesis"
    assert osis_to_spaceless("Exod") == "Exodus"
    assert osis_to_spaceless("Ps") == "Psalms"
    assert osis_to_spaceless("Matt") == "Matthew"
    assert osis_to_spaceless("Rev") == "Revelation"
    assert osis_to_spaceless("1Thess") == "1Thessalonians"
    assert osis_to_spaceless("1Tim") == "1Timothy"
    assert osis_to_spaceless("1Pet") == "1Peter"
    assert osis_to_spaceless("1John") == "1John"
    assert osis_to_spaceless("2John") == "2John"
    assert osis_to_spaceless("3John") == "3John"

    # Deuterocanonical
    assert osis_to_spaceless("Tob") == "Tobit"
    assert osis_to_spaceless("Jdt") == "Judith"
    assert osis_to_spaceless("1Macc") == "1Maccabees"
    assert osis_to_spaceless("Wis") == "Wisdom"
    assert osis_to_spaceless("Sir") == "Sirach"
    assert osis_to_spaceless("Bar") == "Baruch"

    # ── spaceless_to_osis ──
    assert spaceless_to_osis("1Corinthians") == "1Cor", (
        f"spaceless_to_osis('1Corinthians') = {spaceless_to_osis('1Corinthians')!r}"
    )
    assert spaceless_to_osis("2Corinthians") == "2Cor"
    assert spaceless_to_osis("SongofSolomon") == "Song", (
        f"spaceless_to_osis('SongofSolomon') = {spaceless_to_osis('SongofSolomon')!r}"
    )
    assert spaceless_to_osis("1Samuel") == "1Sam"
    assert spaceless_to_osis("2Samuel") == "2Sam"
    assert spaceless_to_osis("1Kings") == "1Kgs"
    assert spaceless_to_osis("2Kings") == "2Kgs"
    assert spaceless_to_osis("1Chronicles") == "1Chr"
    assert spaceless_to_osis("Genesis") == "Gen"
    assert spaceless_to_osis("Exodus") == "Exod"
    assert spaceless_to_osis("Psalms") == "Ps"
    assert spaceless_to_osis("Matthew") == "Matt"
    assert spaceless_to_osis("Revelation") == "Rev"
    assert spaceless_to_osis("1Thessalonians") == "1Thess"
    assert spaceless_to_osis("1Timothy") == "1Tim"
    assert spaceless_to_osis("1Peter") == "1Pet"
    assert spaceless_to_osis("1John") == "1John"
    assert spaceless_to_osis("2John") == "2John"
    assert spaceless_to_osis("3John") == "3John"

    # Deuterocanonical
    assert spaceless_to_osis("Tobit") == "Tob"
    assert spaceless_to_osis("Judith") == "Jdt"
    assert spaceless_to_osis("1Maccabees") == "1Macc"
    assert spaceless_to_osis("Wisdom") == "Wis"
    assert spaceless_to_osis("Sirach") == "Sir"
    assert spaceless_to_osis("Baruch") == "Bar"

    # Unknown spaceless names
    assert spaceless_to_osis("NotABook") is None

    # ── Round-trip: spaceless_to_osis(osis_to_spaceless(x)) == x ──
    round_trip_osis = ["Gen", "Exod", "Lev", "Song", "1Sam", "2Sam",
                       "1Cor", "2Cor", "Matt", "Rev", "1Macc", "Tob"]
    for osis in round_trip_osis:
        rt = spaceless_to_osis(osis_to_spaceless(osis))
        assert rt == osis, (
            f"Round-trip failed for {osis}: "
            f"osis_to_spaceless → {osis_to_spaceless(osis)!r} → "
            f"spaceless_to_osis → {rt!r}"
        )

    # ── Unknown OSIS passes through ──
    assert osis_to_spaceless("BogusBook") == "BogusBook", (
        "osis_to_spaceless of unknown OSIS should return input unchanged"
    )

    print("  ✓ test_commentary_conversion — %d assertions passed" % 92)


# =============================================================================
# 6. OT/NT detection tests
# =============================================================================

def test_testament_detection():
    """is_old_testament and is_new_testament for proto-canonical,
    deuterocanonical, and unknown books."""

    # ── OT proto-canonical ──
    assert is_old_testament("Gen") is True
    assert is_old_testament("Exod") is True
    assert is_old_testament("Ps") is True
    assert is_old_testament("Isa") is True
    assert is_old_testament("Jer") is True
    assert is_old_testament("Ezek") is True
    assert is_old_testament("Dan") is True
    assert is_old_testament("Hos") is True
    assert is_old_testament("Joel") is True
    assert is_old_testament("Amos") is True
    assert is_old_testament("Obad") is True
    assert is_old_testament("Jonah") is True
    assert is_old_testament("Mic") is True
    assert is_old_testament("Nah") is True
    assert is_old_testament("Hab") is True
    assert is_old_testament("Zeph") is True
    assert is_old_testament("Hag") is True
    assert is_old_testament("Zech") is True
    assert is_old_testament("Mal") is True

    # ── NT proto-canonical ──
    assert is_new_testament("Matt") is True
    assert is_new_testament("Mark") is True
    assert is_new_testament("Luke") is True
    assert is_new_testament("John") is True
    assert is_new_testament("Acts") is True
    assert is_new_testament("Rom") is True
    assert is_new_testament("1Cor") is True
    assert is_new_testament("Gal") is True
    assert is_new_testament("Eph") is True
    assert is_new_testament("Phil") is True
    assert is_new_testament("Col") is True
    assert is_new_testament("Heb") is True
    assert is_new_testament("Jas") is True
    assert is_new_testament("Rev") is True

    # ── OT books are NOT new testament ──
    assert is_new_testament("Gen") is False
    assert is_new_testament("Exod") is False
    assert is_new_testament("Ps") is False
    assert is_new_testament("Mal") is False

    # ── NT books are NOT old testament ──
    assert is_old_testament("Matt") is False
    assert is_old_testament("Rev") is False
    assert is_old_testament("1Cor") is False

    # ── Deuterocanonical are OT ──
    assert is_old_testament("Tob") is True, "Tobit should be OT"
    assert is_old_testament("Jdt") is True, "Judith should be OT"
    assert is_old_testament("1Macc") is True, "1 Maccabees should be OT"
    assert is_old_testament("2Macc") is True, "2 Maccabees should be OT"
    assert is_old_testament("Wis") is True, "Wisdom should be OT"
    assert is_old_testament("Sir") is True, "Sirach should be OT"
    assert is_old_testament("Bar") is True, "Baruch should be OT"

    # Deuterocanonical are NOT new testament
    assert is_new_testament("Tob") is False
    assert is_new_testament("1Macc") is False
    assert is_new_testament("Wis") is False

    # ── Unknown books return False for both ──
    assert is_old_testament("xyz") is False
    assert is_new_testament("xyz") is False
    assert is_old_testament("") is False
    assert is_new_testament("") is False

    print("  ✓ test_testament_detection — %d assertions passed" % 56)


# =============================================================================
# 7. Backward compatibility with bolls IDs
# =============================================================================

def test_bolls_ids():
    """resolve_book_id returns correct bolls.life integer book IDs."""

    # Protestant proto-canonical
    assert resolve_book_id("Genesis") == 1
    assert resolve_book_id("Exodus") == 2
    assert resolve_book_id("Leviticus") == 3
    assert resolve_book_id("Numbers") == 4
    assert resolve_book_id("Deuteronomy") == 5
    assert resolve_book_id("Joshua") == 6
    assert resolve_book_id("Judges") == 7
    assert resolve_book_id("Ruth") == 8
    assert resolve_book_id("1 Samuel") == 9
    assert resolve_book_id("2 Samuel") == 10
    assert resolve_book_id("1 Kings") == 11
    assert resolve_book_id("2 Kings") == 12
    assert resolve_book_id("1 Chronicles") == 13
    assert resolve_book_id("2 Chronicles") == 14
    assert resolve_book_id("Ezra") == 15
    assert resolve_book_id("Nehemiah") == 16
    assert resolve_book_id("Esther") == 17
    assert resolve_book_id("Job") == 18
    assert resolve_book_id("Psalms") == 19
    assert resolve_book_id("Proverbs") == 20
    assert resolve_book_id("Ecclesiastes") == 21
    assert resolve_book_id("Song of Solomon") == 22
    assert resolve_book_id("Isaiah") == 23
    assert resolve_book_id("Jeremiah") == 24
    assert resolve_book_id("Lamentations") == 25
    assert resolve_book_id("Ezekiel") == 26
    assert resolve_book_id("Daniel") == 27
    assert resolve_book_id("Hosea") == 28
    assert resolve_book_id("Joel") == 29
    assert resolve_book_id("Amos") == 30
    assert resolve_book_id("Obadiah") == 31
    assert resolve_book_id("Jonah") == 32
    assert resolve_book_id("Micah") == 33
    assert resolve_book_id("Nahum") == 34
    assert resolve_book_id("Habakkuk") == 35
    assert resolve_book_id("Zephaniah") == 36
    assert resolve_book_id("Haggai") == 37
    assert resolve_book_id("Zechariah") == 38
    assert resolve_book_id("Malachi") == 39

    assert resolve_book_id("Matthew") == 40
    assert resolve_book_id("Mark") == 41
    assert resolve_book_id("Luke") == 42
    assert resolve_book_id("John") == 43
    assert resolve_book_id("Acts") == 44
    assert resolve_book_id("Romans") == 45
    assert resolve_book_id("1 Corinthians") == 46
    assert resolve_book_id("2 Corinthians") == 47
    assert resolve_book_id("Galatians") == 48
    assert resolve_book_id("Ephesians") == 49
    assert resolve_book_id("Philippians") == 50
    assert resolve_book_id("Colossians") == 51
    assert resolve_book_id("1 Thessalonians") == 52
    assert resolve_book_id("2 Thessalonians") == 53
    assert resolve_book_id("1 Timothy") == 54
    assert resolve_book_id("2 Timothy") == 55
    assert resolve_book_id("Titus") == 56
    assert resolve_book_id("Philemon") == 57
    assert resolve_book_id("Hebrews") == 58
    assert resolve_book_id("James") == 59
    assert resolve_book_id("1 Peter") == 60
    assert resolve_book_id("2 Peter") == 61
    assert resolve_book_id("1 John") == 62
    assert resolve_book_id("2 John") == 63
    assert resolve_book_id("3 John") == 64
    assert resolve_book_id("Jude") == 65
    assert resolve_book_id("Revelation") == 66

    # Deuterocanonical
    assert resolve_book_id("Tobit") == 68
    assert resolve_book_id("Judith") == 69
    assert resolve_book_id("Wisdom") == 70
    assert resolve_book_id("Sirach") == 71
    assert resolve_book_id("Baruch") == 73
    assert resolve_book_id("1 Maccabees") == 74
    assert resolve_book_id("2 Maccabees") == 75

    # Unknown
    assert resolve_book_id("notabook") is None
    assert resolve_book_id("") is None

    # Also test via abbreviations
    assert resolve_book_id("gen") == 1
    assert resolve_book_id("exo") == 2
    assert resolve_book_id("ps") == 19
    assert resolve_book_id("1cor") == 46
    assert resolve_book_id("2tim") == 55
    assert resolve_book_id("rev") == 66

    print("  ✓ test_bolls_ids — %d assertions passed" % 88)


# =============================================================================
# 8. Integration: consumer modules still work
# =============================================================================

def test_integration():
    """Verify consumer modules import without error and core functionality works."""

    # ── Import all three consumer modules ──
    from accordance_parser import bible_api, text_module, commentary_module
    # If we get here, imports succeeded

    # ── bible_api.parse_reference() returns integer book IDs ──
    result = bible_api.parse_reference("Genesis 1:1")
    assert result is not None, "bible_api.parse_reference('Genesis 1:1') returned None"
    assert isinstance(result, tuple), f"Expected tuple, got {type(result)}"
    assert len(result) == 4, f"Expected tuple of 4, got {len(result)}"
    book_id, ch, v, end_v = result
    assert book_id == 1, f"book_id = {book_id}, expected 1"
    assert ch == 1
    assert v == 1
    assert end_v is None

    result = bible_api.parse_reference("John 3:16")
    assert result is not None
    book_id, ch, v, end_v = result
    assert book_id == 43
    assert ch == 3
    assert v == 16

    result = bible_api.parse_reference("Revelation 22:21")
    assert result is not None
    book_id, ch, v, end_v = result
    assert book_id == 66
    assert ch == 22
    assert v == 21

    # Chapter-only references return None (by design in bible_api)
    result = bible_api.parse_reference("Genesis 1")
    assert result is None, "bible_api should return None for chapter-only references"

    # ── text_module Book aliases resolve via resolve_book ──
    # text_module imports BOOKS_BY_OSIS and resolve_book from canon
    assert text_module.BOOKS_BY_OSIS is BOOKS_BY_OSIS, (
        "text_module.BOOKS_BY_OSIS should be the same dict"
    )

    # Check text_module's resolve_book resolves correctly
    b = text_module.resolve_book("Genesis")
    assert b is not None
    assert b.osis == "Gen"

    b = text_module.resolve_book("gen")
    assert b is not None
    assert b.osis == "Gen"

    b = text_module.resolve_book("1 Corinthians")
    assert b is not None
    assert b.osis == "1Cor"

    # ── commentary_module loads and has expected attributes ──
    assert hasattr(commentary_module, "CANON_PROTESTANT")
    assert hasattr(commentary_module, "CANON_CATHOLIC")
    assert hasattr(commentary_module, "resolve_book")
    assert hasattr(commentary_module, "osis_to_spaceless")
    assert hasattr(commentary_module, "spaceless_to_osis")
    assert hasattr(commentary_module, "chapter_count")
    assert hasattr(commentary_module, "last_verse_of_chapter")

    # ── Commentary spaceless convention still works ──
    b = commentary_module.resolve_book("1Corinthians")
    assert b is not None
    assert b.osis == "1Cor"

    print("  ✓ test_integration — %d assertions passed" % 24)


# =============================================================================
# 9. Psalm numbering: LXX ↔ MT mapping tests
# =============================================================================

from accordance_parser.canon import (
    LXX_TO_MT_PSALM,
    psalm_lxx_to_mt,
    psalm_mt_to_lxx,
)


def test_psalm_lxx_to_mt_basic():
    """Core LXX→MT mapping is correct across all ranges."""

    # Range 1-8: identical
    for ch in range(1, 9):
        assert psalm_lxx_to_mt(ch) == ch, f"LXX {ch} → MT {psalm_lxx_to_mt(ch)}, expected {ch}"

    # LXX 9 → MT 9 (combined case; LXX 9 = MT 9+10, primary is 9)
    assert psalm_lxx_to_mt(9) == 9, f"LXX 9 → MT {psalm_lxx_to_mt(9)}, expected 9"

    # Range 10-112: LXX + 1 = MT
    for lxx in range(10, 113):
        expected = lxx + 1
        assert psalm_lxx_to_mt(lxx) == expected, (
            f"LXX {lxx} → MT {psalm_lxx_to_mt(lxx)}, expected {expected}"
        )

    # LXX 113 → MT 114 (combined case; LXX 113 = MT 114+115, primary is 114)
    assert psalm_lxx_to_mt(113) == 114, f"LXX 113 → MT {psalm_lxx_to_mt(113)}, expected 114"

    # Range 114-115: LXX splits MT 116; both map to MT 116
    assert psalm_lxx_to_mt(114) == 116
    assert psalm_lxx_to_mt(115) == 116

    # Range 116-145: LXX + 1 = MT
    for lxx in range(116, 146):
        expected = lxx + 1
        assert psalm_lxx_to_mt(lxx) == expected, (
            f"LXX {lxx} → MT {psalm_lxx_to_mt(lxx)}, expected {expected}"
        )

    # Range 146-147: LXX splits MT 147; both map to MT 147
    assert psalm_lxx_to_mt(146) == 147
    assert psalm_lxx_to_mt(147) == 147

    # Range 148-150: identical
    for ch in range(148, 151):
        assert psalm_lxx_to_mt(ch) == ch

    print("  ✓ test_psalm_lxx_to_mt_basic — 150 assertions passed")


def test_psalm_lxx_to_mt_key_known_cases():
    """Well-known Psalm numbering divergences are correct."""

    # NABRE Psalm 51 (LXX) = Protestant Psalm 52 (MT)
    # The penitential psalm "Have mercy on me, O God"
    assert psalm_lxx_to_mt(51) == 52, (
        f"LXX 51 (Miserere) should be MT 52, got {psalm_lxx_to_mt(51)}"
    )

    # NABRE Psalm 68 (LXX) = Protestant Psalm 69 (MT)
    # "Save me, O God" (the watery depths psalm)
    assert psalm_lxx_to_mt(68) == 69, (
        f"LXX 68 should be MT 69 ('Save me, O God'), got {psalm_lxx_to_mt(68)}"
    )

    # The famous Psalm 23 ("The Lord is my shepherd") is MT 23.
    # In LXX, this is Psalm 22 (LXX 22 → MT 23).  Psalm 23 in LXX
    # is actually MT 24 ("The earth is the Lord's").
    assert psalm_lxx_to_mt(22) == 23, (
        f"LXX 22 should be MT 23 (The Lord is my shepherd), got {psalm_lxx_to_mt(22)}"
    )

    # Psalm 119 (longest psalm) — LXX 118, MT 119 (LXX 118 → MT 119)
    assert psalm_lxx_to_mt(118) == 119, (
        f"LXX 118 should be MT 119, got {psalm_lxx_to_mt(118)}"
    )

    # Psalm 150 (last psalm) — identical in both
    assert psalm_lxx_to_mt(150) == 150

    # Psalm 1 (first psalm) — identical in both
    assert psalm_lxx_to_mt(1) == 1

    print("  ✓ test_psalm_lxx_to_mt_key_known_cases — %d assertions passed" % 6)


def test_psalm_lxx_to_mt_edge_combined():
    """Edge case: combined Psalms where LXX merges two MT Psalms into one."""

    # LXX 9 = MT 9+10 combined.  The mapping returns MT 9 (primary).
    assert psalm_lxx_to_mt(9) == 9

    # Verify that MT 10 has no LXX equivalent (it's absorbed into LXX 9)
    assert psalm_mt_to_lxx(10) == []

    # LXX 113 = MT 114+115 combined.  The mapping returns MT 114 (primary).
    assert psalm_lxx_to_mt(113) == 114

    # LXX 114 = MT 116:1-9 (first half of MT 116)
    assert psalm_lxx_to_mt(114) == 116

    # LXX 115 = MT 116:10-19 (second half of MT 116)
    assert psalm_lxx_to_mt(115) == 116

    print("  ✓ test_psalm_lxx_to_mt_edge_combined — %d assertions passed" % 5)


def test_psalm_lxx_to_mt_edge_split():
    """Edge case: split Psalms where LXX divides one MT Psalm into two."""

    # MT 116 is split into LXX 114 + 115
    lxx_for_116 = psalm_mt_to_lxx(116)
    assert lxx_for_116 == [114, 115], (
        f"MT 116 should map to LXX [114, 115], got {lxx_for_116}"
    )

    # MT 147 is split into LXX 146 + 147
    lxx_for_147 = psalm_mt_to_lxx(147)
    assert lxx_for_147 == [146, 147], (
        f"MT 147 should map to LXX [146, 147], got {lxx_for_147}"
    )

    # MT 9 maps to LXX [9] (just one LXX chapter; MT 10 is absorbed)
    lxx_for_9 = psalm_mt_to_lxx(9)
    assert lxx_for_9 == [9], f"MT 9 should map to LXX [9], got {lxx_for_9}"

    print("  ✓ test_psalm_lxx_to_mt_edge_split — %d assertions passed" % 3)


def test_psalm_lxx_to_mt_error_paths():
    """psalm_lxx_to_mt raises ValueError for invalid chapter numbers."""

    # Chapter 0
    try:
        psalm_lxx_to_mt(0)
        assert False, "psalm_lxx_to_mt(0) should have raised ValueError"
    except ValueError as e:
        assert "1-150" in str(e) or "0" in str(e)

    # Chapter 151
    try:
        psalm_lxx_to_mt(151)
        assert False, "psalm_lxx_to_mt(151) should have raised ValueError"
    except ValueError as e:
        assert "1-150" in str(e) or "151" in str(e)

    # Negative chapter
    try:
        psalm_lxx_to_mt(-1)
        assert False, "psalm_lxx_to_mt(-1) should have raised ValueError"
    except ValueError as e:
        assert "1-150" in str(e) or "-1" in str(e)

    # Very large number
    try:
        psalm_lxx_to_mt(9999)
        assert False, "psalm_lxx_to_mt(9999) should have raised ValueError"
    except ValueError as e:
        assert "1-150" in str(e)

    print("  ✓ test_psalm_lxx_to_mt_error_paths — %d assertions passed" % 4)


def test_psalm_mt_to_lxx_error_paths():
    """psalm_mt_to_lxx raises ValueError for invalid chapter numbers."""

    try:
        psalm_mt_to_lxx(0)
        assert False, "psalm_mt_to_lxx(0) should have raised ValueError"
    except ValueError:
        pass

    try:
        psalm_mt_to_lxx(151)
        assert False, "psalm_mt_to_lxx(151) should have raised ValueError"
    except ValueError:
        pass

    print("  ✓ test_psalm_mt_to_lxx_error_paths — %d assertions passed" % 2)


def test_psalm_mt_to_lxx_basic():
    """Reverse mapping MT → LXX returns correct list of LXX chapters."""

    # Simple 1:1 mappings (ranges 1-8 and 148-150)
    assert psalm_mt_to_lxx(1) == [1]
    assert psalm_mt_to_lxx(8) == [8]
    assert psalm_mt_to_lxx(148) == [148]
    assert psalm_mt_to_lxx(150) == [150]

    # 1:1 shifted mappings
    assert psalm_mt_to_lxx(11) == [10]
    assert psalm_mt_to_lxx(113) == [112]
    assert psalm_mt_to_lxx(117) == [116]
    assert psalm_mt_to_lxx(146) == [145]

    # Split mappings
    assert psalm_mt_to_lxx(116) == [114, 115]
    assert psalm_mt_to_lxx(147) == [146, 147]

    # MT 10 is absorbed into LXX 9 (no standalone mapping)
    assert psalm_mt_to_lxx(10) == []

    # MT 115 is absorbed into LXX 113 (no standalone mapping)
    assert psalm_mt_to_lxx(115) == []

    print("  ✓ test_psalm_mt_to_lxx_basic — %d assertions passed" % 11)


def test_psalm_lxx_to_mt_lectionary_references():
    """All 8 lectionary Psalm references convert correctly to MT."""

    # fmt: off
    lectionary_conversions = [
        # (LXX lectionary ref, expected MT ref, description)
        ("Psalms 68:2-7",             "Psalms 69:2-7",
         "Monday after Pentecost"),
        ("Psalms 68:10-11, 20-21",    "Psalms 69:10-11, 20-21",
         "Tuesday after Pentecost"),
        ("Psalms 68:29-36",           "Psalms 69:29-36",
         "Wednesday after Pentecost"),
        ("Psalms 16:1-2, 5, 7-11",    "Psalms 17:1-2, 5, 7-11",
         "Thursday after Pentecost"),
        ("Psalms 103:1-2, 11-12, 19-20", "Psalms 104:1-2, 11-12, 19-20",
         "Friday after Pentecost"),
        ("Psalms 11:4-5, 7",          "Psalms 12:4-5, 7",
         "Vigil of Trinity Sunday"),
        ("Psalms 147:12-15, 19-20",   "Psalms 147:12-15, 19-20",
         "Corpus Christi (no change)"),
        ("Psalms 103:1-4, 6-8, 10",   "Psalms 104:1-4, 6-8, 10",
         "Sacred Heart"),
    ]
    # fmt: on

    from accordance_parser.lectionary import _convert_psalm_ref_lxx_to_mt

    for lxx_ref, expected_mt, desc in lectionary_conversions:
        result = _convert_psalm_ref_lxx_to_mt(lxx_ref)
        assert result == expected_mt, (
            f"{desc}: {lxx_ref!r} → {result!r}, expected {expected_mt!r}"
        )

    print("  ✓ test_psalm_lxx_to_mt_lectionary_references — %d assertions passed" % 8)


def test_lectionary_returns_mt_numbered_psalms():
    """Integration test: get_lectionary_readings() returns MT-numbered Psalm refs."""
    import datetime as dt
    from accordance_parser.lectionary import get_lectionary_readings

    # Monday after Pentecost: psalm should be MT 69, not LXX 68
    _, readings = get_lectionary_readings(dt.date(2026, 5, 25))
    assert readings["psalm"] == "Psalms 69:2-7", (
        f"Expected MT psalm ref, got {readings['psalm']!r}"
    )

    # Friday after Pentecost: psalm should be MT 104, not LXX 103
    _, readings = get_lectionary_readings(dt.date(2026, 5, 29))
    assert readings["psalm"] == "Psalms 104:1-2, 11-12, 19-20", (
        f"Expected MT psalm ref, got {readings['psalm']!r}"
    )

    # Corpus Christi: LXX 147 = MT 147 (no change)
    _, readings = get_lectionary_readings(dt.date(2026, 6, 7))
    cs_psalm = readings["psalm"]
    assert cs_psalm.startswith("Psalms 147"), (
        f"Expected Psalms 147, got {cs_psalm!r}"
    )
    # Source site may use different range notation; either is valid
    assert (
        cs_psalm == "Psalms 147:12-15, 19-20"
        or cs_psalm == "Psalms 147:12-13, 14-15, 19-20"
    ), f"Unexpected Psalms 147 format: {cs_psalm!r}"

    # Non-Psalm references (first, gospel) should NOT be changed
    _, readings = get_lectionary_readings(dt.date(2026, 5, 25))
    assert readings["first"] == "Acts 19:1-8", "Non-Psalm ref should be unchanged"
    assert readings["gospel"] == "John 16:29-33", "Non-Psalm ref should be unchanged"

    print("  ✓ test_lectionary_returns_mt_numbered_psalms — %d assertions passed" % 5)


def test_lectionary_non_psalm_unchanged():
    """References that aren't Psalms (e.g., Daniel 3) pass through unchanged."""
    import datetime as dt
    from accordance_parser.lectionary import get_lectionary_readings

    # Trinity Sunday: psalm is "Daniel 3:52-56" (NOT a Psalm reference).
    # The source site may use comma notation ("Daniel 3:52, 53, 54, 55");
    # both forms are equivalent — the key invariant is it's still Daniel 3.
    _, readings = get_lectionary_readings(dt.date(2026, 5, 31))
    psalm = readings["psalm"]
    assert psalm.startswith("Daniel 3"), (
        f"Non-Psalm ref should still be Daniel 3, got {psalm!r}"
    )
    # Both comma and range notations are valid source formats
    assert (
        psalm == "Daniel 3:52-56"
        or psalm == "Daniel 3:52, 53, 54, 55"
    ), f"Unexpected Daniel 3 format: {psalm!r}"

    print("  ✓ test_lectionary_non_psalm_unchanged — %d assertions passed" % 2)


def test_lxx_to_mt_mapping_dictionary_integrity():
    """LXX_TO_MT_PSALM dict has 150 entries, all values 1-150."""
    assert len(LXX_TO_MT_PSALM) == 150, (
        f"Expected 150 entries, got {len(LXX_TO_MT_PSALM)}"
    )
    # All keys and values in range
    for k, v in LXX_TO_MT_PSALM.items():
        assert 1 <= k <= 150, f"Key {k} out of range"
        assert 1 <= v <= 150, f"Value {v} for key {k} out of range"

    # Verify specific known shifts
    assert LXX_TO_MT_PSALM[10] == 11
    assert LXX_TO_MT_PSALM[68] == 69
    assert LXX_TO_MT_PSALM[103] == 104
    assert LXX_TO_MT_PSALM[112] == 113

    print("  ✓ test_lxx_to_mt_mapping_dictionary_integrity — %d assertions passed" % 158)


# =============================================================================
# Runner
# =============================================================================

if __name__ == "__main__":
    test_book_identity()
    test_name_resolution()
    test_reference_parsing()
    test_canons()
    test_commentary_conversion()
    test_testament_detection()
    test_bolls_ids()
    test_integration()
    # Psalm numbering tests
    test_psalm_lxx_to_mt_basic()
    test_psalm_lxx_to_mt_key_known_cases()
    test_psalm_lxx_to_mt_edge_combined()
    test_psalm_lxx_to_mt_edge_split()
    test_psalm_lxx_to_mt_error_paths()
    test_psalm_mt_to_lxx_error_paths()
    test_psalm_mt_to_lxx_basic()
    test_psalm_lxx_to_mt_lectionary_references()
    test_lectionary_returns_mt_numbered_psalms()
    test_lectionary_non_psalm_unchanged()
    test_lxx_to_mt_mapping_dictionary_integrity()
    print("\n=== ALL CANON TESTS PASSED ===")
