"""
Suite 1 — Roundtrip: Hypothesis property-based tests.

Verifies roundtrip and determinism properties for the core Accordance
data pipeline: verse lookup, lexicon entries, commentary sections,
canonical reference construction, and decode cycles.

Properties (5):
  P1 — Verse roundtrip: parsed reference → lookup → result consistency
  P2 — Lexicon lookup determinism: headword → entry → repeat → same output
  P3 — Commentary section determinism: reference → section → same every call
  P4 — Canon reference roundtrip: (osis, ch, v) → string → parse → same osis/ch/v
  P5 — Decode re-decode stability: decode output is stable under Latin-1 re-encode
"""

from __future__ import annotations

from pathlib import Path
from typing import Optional

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

from accordance_parser.bible_api import lookup_kjv_local
from accordance_parser.canon import (
    BOOKS_BY_OSIS,
    CANON_PROTESTANT,
    Book,
    parse_reference,
)
from accordance_parser.encoding import decode, decode_always
from accordance_parser.discovery import discover_modules
from accordance_parser.lexicon_module import LexiconModule
from accordance_parser.commentary_module import CommentaryModule


# ===========================================================================
# Project root — for resolving module file paths
# ===========================================================================

_PROJECT_ROOT = Path(__file__).resolve().parent.parent


def _resolve_module_path(abbr: str, data_file_stem: str) -> Optional[Path]:
    """Find a module's data file in the local modules/ tree.

    Searches Tools/ and Texts/ directories under the project's
    modules/ folder (which mirrors the Accordance system path).
    """
    for subdir in ("Tools", "Texts"):
        for candidate in (_PROJECT_ROOT / "modules" / subdir).iterdir():
            if not candidate.is_dir():
                continue
            data_file = candidate / data_file_stem
            if data_file.is_file():
                return data_file
    return None


# ===========================================================================
# Shared helpers
# ===========================================================================

# Canon data for strategy generation
_PROTESTANT_LAST_VERSE = CANON_PROTESTANT.last_verse
_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]
]


# ===========================================================================
# Strategies
# ===========================================================================


@st.composite
def valid_book_osis(draw):
    """Draw a valid book OSIS with verse data."""
    candidates = [
        osis for osis in _PROTESTANT_LAST_VERSE
        if _PROTESTANT_LAST_VERSE[osis]
    ]
    return draw(st.sampled_from(tuple(candidates)))


@st.composite
def valid_chapter(draw, book_osis: str):
    """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=max(len(chapters), 1)))


@st.composite
def valid_verse(draw, book_osis: str, chapter: int):
    """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_triple(draw):
    """Strategy: generate a valid (osis, chapter, verse) triple."""
    osis = draw(valid_book_osis())
    ch = draw(valid_chapter(osis))
    v = draw(valid_verse(osis, ch))
    return (osis, ch, v)


# ===========================================================================
# P1 — Verse roundtrip: parsed reference → lookup → result consistency
# ===========================================================================


@given(triple=valid_reference_triple())
@settings(
    max_examples=200,
    deadline=5000,
    suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_verse_lookup_roundtrip_consistent(triple: tuple[str, int, int]) -> None:
    """Look up a verse by (osis, ch, v); verify result fields match the request.

    Uses local KJV JSON — no network dependency.
    """
    osis, ch, v = triple
    book = BOOKS_BY_OSIS.get(osis)
    if book is None or book.bolls_id is None:
        pytest.skip(f"No bolls_id for OSIS {osis}")

    result = lookup_kjv_local(book.bolls_id, ch, v)
    if result is None:
        # Some verse combinations may not exist in KJV (e.g., deuterocanonical)
        pytest.skip(f"No KJV data for {osis} {ch}:{v}")

    # The VerseResult should carry matching reference fields
    assert result.book_id == book.bolls_id, (
        f"book_id mismatch: expected {book.bolls_id}, got {result.book_id}"
    )
    assert result.chapter == ch, (
        f"chapter mismatch: expected {ch}, got {result.chapter}"
    )
    assert result.verse == v, (
        f"verse mismatch: expected {v}, got {result.verse}"
    )

    # The .text field should be a non-empty str
    assert isinstance(result.text, str)
    assert len(result.text) > 0, f"Empty text for {osis} {ch}:{v}"

    # The .reference field should be parseable back to the same book
    book_name = book.name
    assert book_name in result.reference, (
        f"Reference string {result.reference!r} does not contain book name {book_name!r}"
    )


# ===========================================================================
# P2 — Lexicon lookup determinism: headword → entry → repeat → same output
# ===========================================================================


def _find_greek_lexicon() -> Optional[LexiconModule]:
    """Find an available Greek lexicon module (BDAG preferred)."""
    registry = discover_modules()
    for abbr, info in registry.items():
        if info["type"] in (3,) and info.get("language") in ("el", "Greek"):
            path = _resolve_module_path(abbr, abbr)
            if path is not None and path.is_file():
                try:
                    return LexiconModule(str(path))
                except Exception:
                    continue
    # Fallback: try any type-3 module by name
    bdag_path = _PROJECT_ROOT / "modules" / "Tools" / "BDAG.atool" / "BDAG"
    if bdag_path.is_file():
        try:
            return LexiconModule(str(bdag_path))
        except Exception:
            pass
    return None


def _find_hebrew_lexicon() -> Optional[LexiconModule]:
    """Find an available Hebrew lexicon module (HALOT preferred)."""
    registry = discover_modules()
    for abbr, info in registry.items():
        if info["type"] in (4,) and info.get("language") in ("he", "Hebrew"):
            path = _resolve_module_path(abbr, abbr)
            if path is not None and path.is_file():
                try:
                    return LexiconModule(str(path))
                except Exception:
                    continue
    # Fallback: try any type-4 module
    halot_path = _PROJECT_ROOT / "modules" / "Tools" / "HALOT.atool" / "HALOT"
    if halot_path.is_file():
        try:
            return LexiconModule(str(halot_path))
        except Exception:
            pass
    return None


def _known_greek_headwords(lm: LexiconModule) -> list[str]:
    """Extract a few non-empty Greek headwords from a lexicon's index."""
    keys = lm.headwords()
    sample = []
    for k in keys:
        if k and any("\u0370" <= c <= "\u03FF" for c in k):
            sample.append(k)
        if len(sample) >= 10:
            break
    return sample


def _known_hebrew_headwords(lm: LexiconModule) -> list[str]:
    """Extract a few non-empty Hebrew headwords from a lexicon's index."""
    keys = lm.headwords()
    sample = []
    for k in keys:
        if k and any("\u0590" <= c <= "\u05FF" for c in k):
            sample.append(k)
        if len(sample) >= 10:
            break
    return sample


class TestLexiconLookupDeterminism:
    """Lexicon lookup is deterministic and returns consistent results."""

    @pytest.fixture(scope="class")
    def greek_lexicon(self):
        """Load a Greek lexicon module once per test class."""
        lm = _find_greek_lexicon()
        if lm is None:
            pytest.skip("No Greek lexicon module available")
        return lm

    @pytest.fixture(scope="class")
    def hebrew_lexicon(self):
        """Load a Hebrew lexicon module once per test class."""
        lm = _find_hebrew_lexicon()
        if lm is None:
            pytest.skip("No Hebrew lexicon module available")
        return lm

    def test_greek_lookup_deterministic(self, greek_lexicon):
        """Greek headword lookup returns same result on repeated calls."""
        headwords = _known_greek_headwords(greek_lexicon)
        if not headwords:
            pytest.skip("No Greek headwords found in lexicon entries")
        for hw in headwords[:5]:
            first = greek_lexicon.lookup(hw)
            second = greek_lexicon.lookup(hw)
            assert first is not None, f"No result for Greek headword {hw!r}"
            assert first == second, (
                f"Determinism failed for {hw!r}:\n  first={first[:100]!r}\n second={second[:100]!r}"
            )

    def test_hebrew_lookup_deterministic(self, hebrew_lexicon):
        """Hebrew headword lookup returns same result on repeated calls."""
        headwords = _known_hebrew_headwords(hebrew_lexicon)
        if not headwords:
            pytest.skip("No Hebrew headwords found in lexicon entries")
        for hw in headwords[:5]:
            first = hebrew_lexicon.lookup(hw)
            second = hebrew_lexicon.lookup(hw)
            assert first is not None, f"No result for Hebrew headword {hw!r}"
            assert first == second, (
                f"Determinism failed for {hw!r}"
            )

    def test_lookup_result_is_string(self, greek_lexicon):
        """Lexicon lookup always returns a str (never bytes or None)."""
        headwords = _known_greek_headwords(greek_lexicon)
        if not headwords:
            pytest.skip("No headwords found")
        for hw in headwords[:5]:
            result = greek_lexicon.lookup(hw)
            assert isinstance(result, str), (
                f"Expected str, got {type(result).__name__} for {hw!r}"
            )

    def test_lookup_via_headwords_list(self, greek_lexicon):
        """Headwords from headwords() are findable via lookup()."""
        headwords = greek_lexicon.headwords()
        if not headwords:
            pytest.skip("No headwords in lexicon")
        sample = [hw for hw in headwords[:20] if any("\u0370" <= c <= "\u03FF" for c in hw)]
        if not sample:
            pytest.skip("No Greek headwords found")
        for hw in sample[:5]:
            result = greek_lexicon.lookup(hw)
            assert result is not None, (
                f"headwords() key {hw!r} not found by lookup()"
            )


# ===========================================================================
# P3 — Commentary section determinism: same ref → same result every call
# ===========================================================================


def _find_commentary_module() -> Optional[CommentaryModule]:
    """Find an available commentary module."""
    # Try well-known modules in order
    known = [
        ("JFB.atool", "JFB"),
        ("ESV Notes.atool", "ESV Notes"),
        ("NABRE Notes.atool", "NABRE Notes"),
    ]
    for bundle, stem in known:
        path = _PROJECT_ROOT / "modules" / "Tools" / bundle / stem
        if path.is_file():
            try:
                return CommentaryModule(str(path))
            except Exception:
                continue

    # Fallback: discover any type-5 module
    registry = discover_modules()
    for abbr, info in registry.items():
        if info["type"] == 5:
            path = _resolve_module_path(abbr, abbr)
            if path is not None and path.is_file():
                try:
                    cm = CommentaryModule(str(path))
                    if cm.format != "generic" and hasattr(cm, "_entries") and cm._entries:
                        return cm
                except Exception:
                    continue
    return None


# Well-known references that should return content in most commentaries
_KNOWN_COMMENTARY_REFS = [
    "Genesis 1:1",
    "Exodus 20:1",
    "Psalm 23:1",
    "Isaiah 40:1",
    "Matthew 1:1",
    "Mark 1:1",
    "John 1:1",
    "John 3:16",
    "Romans 1:1",
    "Romans 8:28",
    "1 Corinthians 15:1",
    "Revelation 1:1",
]


class TestCommentarySectionDeterminism:
    """Commentary get_section is deterministic."""

    @pytest.fixture(scope="class")
    def commentary(self):
        """Load a commentary module once per test class."""
        cm = _find_commentary_module()
        if cm is None:
            pytest.skip("No commentary module available")
        return cm

    def test_deterministic_on_known_refs(self, commentary):
        """Same reference always returns the same commentary section text."""
        tested = 0
        for ref in _KNOWN_COMMENTARY_REFS:
            first = commentary.get_section(ref)
            second = commentary.get_section(ref)
            assert first == second, (
                f"Determinism failed for {ref!r} in {commentary.abbrev}"
            )
            tested += 1
            if tested >= 3:
                # Enough to establish determinism without being slow
                break

    def test_section_returns_non_empty(self, commentary):
        """Valid references return non-empty text."""
        for ref in _KNOWN_COMMENTARY_REFS[:3]:
            result = commentary.get_section(ref)
            assert isinstance(result, str), (
                f"Expected str, got {type(result).__name__} for {ref!r}"
            )
            assert len(result.strip()) > 0, (
                f"Empty result for {ref!r}"
            )

    def test_chapter_verse_refs_resolve(self, commentary):
        """Verse-level references resolve without fallback notes (when available)."""
        results = []
        test_refs = ["Genesis 1:1", "John 1:1", "Romans 8:1"]
        for ref in test_refs:
            result = commentary.get_section(ref)
            if result and "[Note:" not in result[:20]:
                results.append(ref)
        # At least one should resolve without a fallback note
        assert len(results) >= 1, (
            f"None of {test_refs} resolved without fallback"
        )


# ===========================================================================
# P4 — Canon reference roundtrip: (osis, ch, v) → string → parse → same
# ===========================================================================


@given(triple=valid_reference_triple())
@settings(
    max_examples=300,
    deadline=2000,
    suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_canon_reference_roundtrip(triple: tuple[str, int, int]) -> None:
    """Construct a reference string from (osis, ch, v), parse it back,
    and verify the structured result matches the original triple.
    """
    osis, ch, v = triple
    book = BOOKS_BY_OSIS.get(osis)
    if book is None:
        pytest.skip(f"Unknown OSIS {osis}")

    # Build reference string using the book's canonical name
    ref_str = f"{book.name} {ch}:{v}"

    # Parse it back
    parsed = parse_reference(ref_str)
    assert parsed is not None, (
        f"Failed to parse constructed reference {ref_str!r}"
    )

    assert parsed.book_osis == osis, (
        f"OSIS mismatch: {osis!r} != {parsed.book_osis!r} for {ref_str!r}"
    )
    assert parsed.chapter == ch, (
        f"Chapter mismatch: {ch} != {parsed.chapter} for {ref_str!r}"
    )
    assert parsed.verse == v, (
        f"Verse mismatch: {v} != {parsed.verse} for {ref_str!r}"
    )


@given(triple=valid_reference_triple())
@settings(
    max_examples=200,
    deadline=2000,
    suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_reference_roundtrip_with_osis_string(triple: tuple[str, int, int]) -> None:
    """Parse reference using OSIS code as book name (e.g. 'Gen 1:1').
    The result should still resolve to the same book.
    """
    osis, ch, v = triple
    book = BOOKS_BY_OSIS.get(osis)
    if book is None:
        pytest.skip(f"Unknown OSIS {osis}")

    ref_str = f"{osis} {ch}:{v}"
    parsed = parse_reference(ref_str)
    if parsed is None:
        pytest.skip(f"OSIS code {osis!r} not parseable as reference string")
    assert parsed.book_osis == osis, (
        f"OSIS roundtrip failed: {osis!r} != {parsed.book_osis!r} for {ref_str!r}"
    )


# ===========================================================================
# P5 — Decode re-decode stability
# ===========================================================================

# Pure ASCII (no diacritic triggers like / or \)
_pure_ascii_alphabet = bytes(
    b for b in range(0x20, 0x7F)
    if b not in (0x2F, 0x5C)  # exclude / and \
)
pure_ascii = st.binary(
    min_size=0, max_size=200
).filter(
    lambda b: all(0x20 <= x < 0x7F and x not in (0x2F, 0x5C) for x in b)
)

# Arbitrary bytes
arbitrary_bytes = st.binary(max_size=200)


@given(pure_ascii)
@settings(max_examples=300, deadline=2000)
def test_decode_reencode_stability_ascii(data: bytes) -> None:
    """decode(data) output is stable when re-encoded to Latin-1 and re-decoded.

    For pure ASCII input (no Greek diacritics), decode acts as an identity
    function that decodes via Latin-1. Re-encoding the result to Latin-1
    bytes and calling decode again should produce the same output.
    """
    first = decode(data)
    # Re-encode via Latin-1 (pure ASCII is Latin-1 safe)
    reencoded = first.encode("latin-1", errors="replace")
    second = decode(reencoded)
    assert first == second, (
        f"Re-decode stability failed:\n  first={first!r}\n second={second!r}"
    )


@given(arbitrary_bytes)
@settings(max_examples=300, deadline=2000)
def test_decode_always_reencode_stability(data: bytes) -> None:
    """decode_always(decode_always(data).encode('utf-8').decode('utf-8')) == decode_always(data).

    decode_always converts ASCII → Greek. Once decoded, round-tripping
    the output through UTF-8 encode/decode and re-decoding should be stable
    (since the original bytes no longer have Accordance encoding quirks).
    """
    first = decode_always(data)
    # Roundtrip through UTF-8
    utf8_bytes = first.encode("utf-8")
    utf8_text = utf8_bytes.decode("utf-8")
    second = decode_always(utf8_bytes)
    assert first == utf8_text, (
        f"UTF-8 roundtrip altered text: {first[:80]!r} != {utf8_text[:80]!r}"
    )


@given(arbitrary_bytes)
@settings(max_examples=300, deadline=2000)
def test_decode_output_is_valid_unicode(data: bytes) -> None:
    """Every decode operation produces valid Unicode with no surrogates
    and no replacement characters from decode errors.
    """
    for fn, name in [(decode, "decode"), (decode_always, "decode_always")]:
        result = fn(data)
        assert isinstance(result, str), (
            f"{name} returned {type(result).__name__}, not str"
        )
        # No lone surrogates
        for i, ch in enumerate(result):
            assert not (0xD800 <= ord(ch) <= 0xDFFF), (
                f"{name} produced surrogate U+{ord(ch):04X} at position {i}"
            )
