"""
Suite 3: Reference Parse Stability — property-based tests.

Properties tested:
  1.  parse_reference determinism (idempotence, whitespace invariance)
  2.  Alias resolution consistency
  3.  Inverse functions (osis_to_spaceless/spaceless_to_osis,
      psalm_lxx_to_mt/psalm_mt_to_lxx)

Uses Hypothesis for property-based generation of reference strings.
"""

from __future__ import annotations

import pytest
from hypothesis import given, settings, strategies as st
from hypothesis import HealthCheck

from accordance_parser.canon import (
    Book,
    BOOKS_BY_OSIS,
    OSIS_TO_SPACELESS,
    SPACELESS_TO_OSIS,
    ReferenceResult,
    parse_reference,
    resolve_book,
    osis_to_spaceless,
    spaceless_to_osis,
    psalm_lxx_to_mt,
    psalm_mt_to_lxx,
)


# ===========================================================================
# Helper strategies
# ===========================================================================


# Import CANON_PROTESTANT's last_verse for strategy generation.
from accordance_parser.canon import CANON_PROTESTANT
_PROTESTANT_LAST_VERSE = CANON_PROTESTANT.last_verse

# All known OSIS codes from the BOOKS_BY_OSIS registry.
_ALL_OSIS_VALUES = sorted(BOOKS_BY_OSIS)

# Books that have verse-count data (non-empty last_verse).
_BOOKS_WITH_VERSES: list[Book] = [
    b for b in BOOKS_BY_OSIS.values()
    if b.osis in _PROTESTANT_LAST_VERSE and _PROTESTANT_LAST_VERSE[b.osis]
]


@st.composite
def valid_book_osis(draw):
    """Strategy: draw a valid book OSIS."""
    return draw(st.sampled_from(tuple(_PROTESTANT_LAST_VERSE)))


@st.composite
def valid_chapter(draw, book_osis: str):
    """Strategy: draw a valid chapter for a given book OSIS."""
    chapters = _PROTESTANT_LAST_VERSE.get(book_osis, ())
    if not chapters:
        return draw(st.just(1))
    return draw(st.integers(min_value=1, max_value=len(chapters)))


@st.composite
def valid_verse(draw, book_osis: str, chapter: int):
    """Strategy: draw a valid verse for a given book and chapter."""
    chapters = _PROTESTANT_LAST_VERSE.get(book_osis, ())
    if not chapters or chapter < 1 or chapter > len(chapters):
        return draw(st.just(1))
    last_v = chapters[chapter - 1]
    if last_v < 1:
        return draw(st.just(1))
    return draw(st.integers(min_value=1, max_value=last_v))


@st.composite
def valid_reference_string(draw):
    """Strategy: generate a valid reference string like 'Genesis 1:1'."""
    osis = draw(valid_book_osis())
    book_obj = BOOKS_BY_OSIS.get(osis)
    if book_obj is None:
        return draw(st.just("Genesis 1:1"))
    ch = draw(valid_chapter(osis))
    v = draw(valid_verse(osis, ch))
    return f"{book_obj.name} {ch}:{v}"


# ===========================================================================
# Group 1 — Determinism
# ===========================================================================


class TestParseReferenceDeterminism:
    """parse_reference() is idempotent and deterministic."""

    @given(ref=valid_reference_string())
    @settings(
        max_examples=500,
        suppress_health_check=[HealthCheck.function_scoped_fixture],
    )
    def test_idempotent_on_valid_refs(self, ref: str):
        """Calling parse_reference twice on the same input yields equal results."""
        first = parse_reference(ref)
        second = parse_reference(ref)
        assert first == second, f"Idempotency failed for {ref!r}"

    @given(ref=valid_reference_string())
    @settings(
        max_examples=300,
        suppress_health_check=[HealthCheck.function_scoped_fixture],
    )
    def test_whitespace_noise_invariant(self, ref: str):
        """Extra whitespace around the reference does not affect parsing."""
        base = parse_reference(ref)
        padded = parse_reference(f"  {ref}  ")
        assert base == padded, f"Whitespace invariant failed for {ref!r}"

    def test_deterministic_on_standard_references(self):
        """Determinism holds for a fixed set of well-known references."""
        refs = [
            "Genesis 1:1",
            "Genesis 1:1-3",
            "Exodus 20:1-17",
            "Psalm 23:1",
            "Isaiah 40:31",
            "Matthew 5:3-12",
            "John 3:16",
            "John 3:16-17",
            "Romans 8:28",
            "Revelation 22:21",
            "1 Samuel 3:4",
            "Song of Solomon 1:1",
            "Jude 4",
            "Obadiah 1:1",
            "Philemon 1:1",
        ]
        for ref in refs:
            a = parse_reference(ref)
            b = parse_reference(ref)
            assert a == b, f"Determinism failed for {ref!r}: {a} != {b}"

    def test_deterministic_comma_separated_references(self):
        """Comma-separated skip-ranges are deterministic."""
        refs = [
            "Exodus 34:4-6, 8-9",
            "Psalms 67:2-3, 5, 6+8",
            "Psalms 16:5, 8, 9-10, 11b",
        ]
        for ref in refs:
            a = parse_reference(ref)
            b = parse_reference(ref)
            assert a == b, f"Determinism failed for {ref!r}"

    def test_deterministic_semicolon_references(self):
        """Semicolon-separated references are deterministic."""
        refs = [
            "Acts 22:30; 23:6-11",
        ]
        for ref in refs:
            a = parse_reference(ref)
            b = parse_reference(ref)
            assert a == b, f"Determinism failed for {ref!r}"

    def test_empty_and_invalid_input_is_deterministic(self):
        """Empty and invalid inputs always return None."""
        invalid = ["", "   ", "not a book ref", "FakeBook 1:1"]
        for ref in invalid:
            assert parse_reference(ref) == parse_reference(ref)

    @given(osis=st.sampled_from(tuple(_ALL_OSIS_VALUES)))
    def test_same_canonical_ref_different_strings_same_book(
        self, osis: str,
    ):
        """Different string representations of the same canonical
        reference produce the same book_osis."""
        book = BOOKS_BY_OSIS[osis]
        ch = 1
        v = 1
        # Use the canonical name and a short alias if one exists
        ref1 = f"{book.name} {ch}:{v}"
        ref2 = f"{osis} {ch}:{v}"
        r1 = parse_reference(ref1)
        r2 = parse_reference(ref2)
        if r1 is not None and r2 is not None:
            assert r1.book_osis == r2.book_osis, (
                f"book_osis mismatch: {ref1!r} → {r1.book_osis}, "
                f"{ref2!r} → {r2.book_osis}"
            )


# ===========================================================================
# Group 2 — Alias Resolution
# ===========================================================================


class TestAliasResolution:
    """All aliases for a book resolve to the same osis."""

    # Known alias groups — each list contains strings that all resolve to
    # the same book.  Only one entry is the "canonical" OSIS; the rest are
    # aliases tested via resolve_book.
    ALIAS_GROUPS: list[tuple[str, list[str]]] = [
        ("Gen",  ["Genesis", "genesis", "Gen", "gen", "1mose", "1Mose"]),
        ("Exod", ["Exodus", "exodus", "Exod", "exo", "2mose", "2Mose"]),
        ("Lev",  ["Leviticus", "leviticus", "Lev", "lev", "3mose"]),
        ("Num",  ["Numbers", "numbers", "Num", "num", "4mose"]),
        ("Deut", ["Deuteronomy", "deuteronomy", "Deut", "deut", "dt", "5mose"]),
        ("Josh", ["Joshua", "joshua", "Josh", "josh", "iosue", "Ios"]),
        ("Judg", ["Judges", "judges", "Judg", "jdg", "iudicum"]),
        ("Ruth", ["Ruth", "ruth"]),
        ("1Sam", ["1 Samuel", "1samuel", "1Sam", "1sam"]),
        ("2Sam", ["2 Samuel", "2samuel", "2Sam", "2sam"]),
        ("1Kgs", ["1 Kings", "1kings", "1Kgs", "iregum"]),
        ("2Kgs", ["2 Kings", "2kings", "2Kgs", "iiregum"]),
        ("1Chr", ["1 Chronicles", "1chronicles", "1Chr", "1chr", "iparalipomenon"]),
        ("2Chr", ["2 Chronicles", "2chronicles", "2Chr", "2chr"]),
        ("Ezra", ["Ezra", "ezra", "iesdrae"]),
        ("Neh",  ["Nehemiah", "nehemiah", "Neh", "neh"]),
        ("Esth", ["Esther", "esther", "Esth", "est"]),
        ("Job",  ["Job", "job", "iob"]),
        ("Ps",   ["Psalms", "psalms", "Ps", "ps", "psa", "psalm", "psalter",
                  "Psalm"]),
        ("Prov", ["Proverbs", "proverbs", "Prov", "prov", "sprueche"]),
        ("Eccl", ["Ecclesiastes", "ecclesiastes", "Eccl", "ecc", "qoheleth",
                  "qoh", "prediger"]),
        ("Song", ["Song of Solomon", "Song of Songs", "canticles",
                  "songofsongs", "Song", "canticumcanticorum", "hohelied"]),
        ("Isa",  ["Isaiah", "isaiah", "Isa", "isa", "isaias", "jesaja"]),
        ("Jer",  ["Jeremiah", "jeremiah", "Jer", "jer", "ieremias"]),
        ("Lam",  ["Lamentations", "lamentations", "Lam", "klagelieder"]),
        ("Ezek", ["Ezekiel", "ezekiel", "Ezek", "ezk", "ezechiel", "hesk"]),
        ("Dan",  ["Daniel", "daniel", "Dan", "dan"]),
        ("Hos",  ["Hosea", "hosea", "Hos", "hos", "osee"]),
        ("Joel", ["Joel", "joel", "ioel"]),
        ("Amos", ["Amos", "amos", "am"]),
        ("Obad", ["Obadiah", "obadiah", "Obad", "abdias"]),
        ("Jonah", ["Jonah", "jonah", "Jon", "ionas"]),
        ("Mic",  ["Micah", "micah", "Mic", "michaeas"]),
        ("Nah",  ["Nahum", "nahum", "Nah"]),
        ("Hab",  ["Habakkuk", "habakkuk", "Hab", "habacuc"]),
        ("Zeph", ["Zephaniah", "zephaniah", "Zeph", "zep", "sophonias"]),
        ("Hag",  ["Haggai", "haggai", "Hag", "aggaeus"]),
        ("Zech", ["Zechariah", "zechariah", "Zech", "zec", "zacharias"]),
        ("Mal",  ["Malachi", "malachi", "Mal"]),
        # NT
        ("Matt", ["Matthew", "matthew", "Matt", "matt", "mt"]),
        ("Mark", ["Mark", "mark", "mk", "marc"]),
        ("Luke", ["Luke", "luke", "lk", "luc"]),
        ("John", ["John", "john", "jn", "ioannes"]),
        ("Acts", ["Acts", "acts", "actus"]),
        ("Rom",  ["Romans", "romans", "Rom", "rom"]),
        ("1Cor", ["1 Corinthians", "1corinthians", "1Cor", "1cor", "icor"]),
        ("2Cor", ["2 Corinthians", "2corinthians", "2Cor", "2cor", "iicor"]),
        ("Gal",  ["Galatians", "galatians", "Gal", "gal"]),
        ("Eph",  ["Ephesians", "ephesians", "Eph", "eph"]),
        ("Phil", ["Philippians", "philippians", "Phil", "php"]),
        ("Col",  ["Colossians", "colossians", "Col"]),
        ("1Thess", ["1 Thessalonians", "1thessalonians", "1Thess", "1th"]),
        ("2Thess", ["2 Thessalonians", "2thessalonians", "2Thess", "2th"]),
        ("1Tim", ["1 Timothy", "1timothy", "1Tim"]),
        ("2Tim", ["2 Timothy", "2timothy", "2Tim"]),
        ("Titus", ["Titus", "titus", "Tit"]),
        ("Phlm", ["Philemon", "philemon", "Phlm"]),
        ("Heb",  ["Hebrews", "hebrews", "Heb", "hebraeos"]),
        ("Jas",  ["James", "james", "Jas", "iacobi"]),
        ("1Pet", ["1 Peter", "1peter", "1Pet", "1pt", "ipetr"]),
        ("2Pet", ["2 Peter", "2peter", "2Pet", "2pt", "iipetr"]),
        ("1John", ["1 John", "1john", "1John", "1jn"]),
        ("2John", ["2 John", "2john", "2John", "2jn"]),
        ("3John", ["3 John", "3john", "3John", "3jn"]),
        ("Jude", ["Jude", "jude", "Jud"]),
        ("Rev",  ["Revelation", "revelation", "Rev", "apocalypse",
                  "apocalypsis", "apoc", "offenbarung", "offb"]),
        # Deuterocanonical
        ("Tob", ["Tobit", "tobit", "Tob", "tobiae"]),
        ("Jdt", ["Judith", "judith", "Jdt", "jth", "iudth"]),
        ("Wis", ["Wisdom", "wisdom", "Wis", "sapientiae", "weisheit"]),
        ("Sir", ["Sirach", "sirach", "Sir", "ecclesiasticus", "eccli"]),
        ("Bar", ["Baruch", "baruch", "Bar"]),
        ("1Macc", ["1 Maccabees", "1maccabees", "1Macc", "1mac", "imachabaeorum"]),
        ("2Macc", ["2 Maccabees", "2maccabees", "2Macc", "2mac"]),
    ]

    @pytest.mark.parametrize("target_osis,aliases", ALIAS_GROUPS)
    def test_all_aliases_resolve_to_correct_osis(
        self, target_osis: str, aliases: list[str],
    ):
        """Every alias in the group resolves to the expected OSIS."""
        for alias in aliases:
            book = resolve_book(alias)
            assert book is not None, (
                f"Alias {alias!r} did not resolve (expected osis={target_osis})"
            )
            assert book.osis == target_osis, (
                f"Alias {alias!r} resolved to {book.osis!r}, expected {target_osis!r}"
            )

    @pytest.mark.parametrize("target_osis,aliases", ALIAS_GROUPS)
    def test_aliases_produce_same_parse_reference_book(
        self, target_osis: str, aliases: list[str],
    ):
        """Aliases for the same book produce the same book_osis in
        parse_reference results."""
        results: list[ReferenceResult | None] = []
        for alias in aliases:
            r = parse_reference(f"{alias} 1:1")
            if r is not None:
                results.append(r)
        if results:
            first_osis = results[0].book_osis
            for r in results[1:]:
                assert r.book_osis == first_osis, (
                    f"Alias mismatch: {results[0]!r} vs {r!r} "
                    f"for group {target_osis}"
                )

    def test_unknown_book_resolves_to_none(self):
        """resolve_book returns None for unknown names."""
        assert resolve_book("DefinitelyNotABook") is None
        assert resolve_book("xylophone") is None

    def test_resolve_book_case_insensitive(self):
        """resolve_book is case-insensitive."""
        lower = resolve_book("genesis")
        upper = resolve_book("GENESIS")
        mixed = resolve_book("GeNeSiS")
        assert lower is not None
        assert upper is not None
        assert mixed is not None
        assert lower.osis == upper.osis == mixed.osis == "Gen"

    def test_resolve_book_spaceless_forms(self):
        """Spaceless book names (e.g., '1Samuel') resolve correctly."""
        tests = [
            ("1Samuel", "1Sam"),
            ("2Samuel", "2Sam"),
            ("1Kings", "1Kgs"),
            ("2Kings", "2Kgs"),
            ("1Chronicles", "1Chr"),
            ("2Chronicles", "2Chr"),
            ("SongofSolomon", "Song"),
            ("SongofSongs", "Song"),
        ]
        for name, expected in tests:
            book = resolve_book(name)
            assert book is not None, f"Could not resolve {name!r}"
            assert book.osis == expected, (
                f"{name!r} resolved to {book.osis!r}, expected {expected!r}"
            )


# ===========================================================================
# Group 3 — Inverse Functions (round-trip properties)
# ===========================================================================


class TestOsisSpacelessRoundTrip:
    """osis_to_spaceless and spaceless_to_osis are inverses."""

    def test_all_osis_round_trip(self):
        """For every OSIS code with a spaceless mapping,
        spaceless_to_osis(osis_to_spaceless(osis)) == osis."""
        for osis in _ALL_OSIS_VALUES:
            if osis not in OSIS_TO_SPACELESS:
                continue
            spaceless = osis_to_spaceless(osis)
            back = spaceless_to_osis(spaceless)
            assert back == osis, (
                f"Round-trip failed: {osis=} → {spaceless=} → {back=}"
            )

    def test_all_spaceless_names_map_to_valid_osis(self):
        """Every spaceless name maps to an OSIS in BOOKS_BY_OSIS."""
        for spaceless, osis in SPACELESS_TO_OSIS.items():
            assert osis in BOOKS_BY_OSIS, (
                f"Spaceless {spaceless!r} maps to unknown OSIS {osis!r}"
            )

    def test_spaceless_entries_symmetric(self):
        """SPACELESS_TO_OSIS and OSIS_TO_SPACELESS are symmetric."""
        for osis, spaceless in OSIS_TO_SPACELESS.items():
            assert SPACELESS_TO_OSIS.get(spaceless) == osis, (
                f"Assymetry: OSIS {osis} → spaceless '{spaceless}' "
                f"but spaceless_to_osis('{spaceless}') → "
                f"{SPACELESS_TO_OSIS.get(spaceless)}"
            )


class TestPsalmNumberingInverse:
    """Psalm LXX/MT numbering functions are inverse pairs."""

    def test_mt_to_lxx_to_mt_round_trip(self):
        """For every valid MT Psalm chapter (1-150),
        mapping to LXX and back (via lxx_to_mt) returns the original,
        or is a legitimate secondary portion of a combined psalm."""
        # Chapters that are secondary in combined LXX Psalms:
        # MT 10 is combined with MT 9 into LXX 9 (primary: MT 9)
        # MT 115 is combined with MT 114 into LXX 113 (primary: MT 114)
        _SECONDARY_MERGE: set[int] = {10, 115}
        for mt in range(1, 151):
            lxx_list = psalm_mt_to_lxx(mt)
            if mt in _SECONDARY_MERGE:
                # Secondary portions of combined psalms have no direct
                # LXX chapter mapping (the LXX chapter maps to the primary).
                assert lxx_list == [], (
                    f"MT {mt} (secondary merge) should have empty img: {lxx_list}"
                )
            else:
                assert lxx_list, f"psalm_mt_to_lxx({mt}) returned empty list"
                for lxx in lxx_list:
                    back = psalm_lxx_to_mt(lxx)
                    assert back == mt, (
                        f"Round-trip failed: MT {mt} → LXX {lxx} → MT {back}"
                    )

    def test_lxx_to_mt_always_in_mt_range(self):
        """Every LXX chapter 1-150 maps to a valid MT chapter in 1-150."""
        for lxx in range(1, 151):
            mt = psalm_lxx_to_mt(lxx)
            assert 1 <= mt <= 150, (
                f"psalm_lxx_to_mt({lxx}) = {mt}, out of range"
            )

    def test_lxx_to_mt_in_mt_to_lxx_images(self):
        """For every LXX chapter, the resulting MT chapter is one that
        psalm_mt_to_lxx maps back to."""
        for lxx in range(1, 151):
            mt = psalm_lxx_to_mt(lxx)
            mt_images = psalm_mt_to_lxx(mt)
            assert lxx in mt_images, (
                f"LXX {lxx} → MT {mt} but MT {mt} maps to {mt_images}"
            )

    def test_psalm_lxx_to_mt_raises_on_invalid(self):
        """Invalid Psalm chapter numbers raise ValueError."""
        from accordance_parser.canon import psalm_lxx_to_mt as plm, psalm_mt_to_lxx as pml
        for val in (-1, 0, 151, 200):
            with pytest.raises(ValueError):
                plm(val)
            with pytest.raises(ValueError):
                pml(val)

    def test_psalm_mt_to_lxx_returns_sorted_unique(self):
        """psalm_mt_to_lxx returns a sorted list with no duplicates."""
        for mt in range(1, 151):
            result = psalm_mt_to_lxx(mt)
            assert result == sorted(set(result)), (
                f"psalm_mt_to_lxx({mt}) not sorted+unique: {result}"
            )

    def test_known_lxx_mt_pairs(self):
        """Verify known LXX/MT psalm numbering pairs."""
        # Identical ranges
        for ch in range(1, 9):
            assert psalm_lxx_to_mt(ch) == ch
            assert psalm_mt_to_lxx(ch) == [ch]
        # LXX 9 = combined MT 9+10 → primary is 9
        assert psalm_lxx_to_mt(9) == 9
        assert psalm_mt_to_lxx(9) == [9]
        # MT 10 is secondary in combined LXX 9; no direct LXX mapping exists.
        assert psalm_mt_to_lxx(10) == []
        # Shift region: LXX 10-112 → MT 11-113
        assert psalm_lxx_to_mt(10) == 11
        assert psalm_lxx_to_mt(112) == 113
        # LXX 113 = combined MT 114+115 → primary 114
        assert psalm_lxx_to_mt(113) == 114
        assert set(psalm_mt_to_lxx(114)) == {113}
        # MT 115 is secondary in combined LXX 113; no direct LXX mapping.
        assert psalm_mt_to_lxx(115) == []
        # LXX 114-115 = split MT 116
        assert psalm_lxx_to_mt(114) == 116
        assert psalm_lxx_to_mt(115) == 116
        assert set(psalm_mt_to_lxx(116)) == {114, 115}
        # End region: 148-150 identical
        for ch in range(148, 151):
            assert psalm_lxx_to_mt(ch) == ch
            assert psalm_mt_to_lxx(ch) == [ch]
