#!/usr/bin/env python3
"""
Pytest tests for Search Index, Cross-References, and Transliteration domain.

12 multi-step scenarios (A1-1 through A1-12). Focused on testable pure functions
and lightweight integration tests that don't require network access.
"""

from __future__ import annotations

import tempfile
import threading
import unicodedata
from pathlib import Path

import pytest

# ---------------------------------------------------------------------------
# Direct imports from the Accordance Port package
# ---------------------------------------------------------------------------

from accordance_parser.search_index import (
    SearchIndex,
    detect_language,
    strip_diacritics,
)

from accordance_parser.transliteration import (
    transliterate_greek,
    transliterate_hebrew,
    reverse_transliterate_greek,
    reverse_transliterate_greek_variants,
    reverse_transliterate_hebrew,
    Transliterator,
)

# ---------------------------------------------------------------------------
# Guard: check whether KJV JSON data is available (needed for Strong's tests)
# ---------------------------------------------------------------------------

_KJV_PATHS = [
    Path(__file__).resolve().parent / "data" / "kjv.json",
    Path.home() / ".accordance" / "kjv.json",
]
_KJV_PATH = next((p for p in _KJV_PATHS if p.exists()), None)
KJV_AVAILABLE = _KJV_PATH is not None and _KJV_PATH.stat().st_size > 0

# All tests in this file require the search index module (which may trigger
# expensive operations if a real DB is present).  Mark them as "slow" so they
# are excluded from the default CI run.  Run explicitly with: pytest -m slow
pytestmark = pytest.mark.slow


# =============================================================================
# A1-1: Cross-Reference Chain — Testable via Strong's index methods
# =============================================================================


class TestCrossReferenceChain:
    """Test the forward index verse→Strong's chain and Strong's→verse reverse
    lookup using local KJV JSON data."""

    def test_verse_strongs_chain_genesis_1_1(self):
        """Genesis 1:1 in KJV: verse → Strong's list → verify known codes."""
        if not KJV_AVAILABLE:
            pytest.skip("KJV JSON not available")
        from accordance_parser.bible_api import get_verse_strongs, BOOK_NAME_TO_ID

        book_id = BOOK_NAME_TO_ID.get("genesis")
        assert book_id is not None, "Genesis not found in book name map"
        strongs = get_verse_strongs(book_id, 1, 1)
        assert strongs is not None, "Genesis 1:1 should have Strong's data"
        assert len(strongs) >= 2, f"Expected >=2 Strong's numbers, got {len(strongs)}"
        # Genesis is OT → H-prefix
        assert all(s.startswith("H") for s in strongs), \
            f"OT verse should have H-prefix numbers, got: {strongs[:3]}"

    def test_verse_strongs_chain_john_3_16(self):
        """John 3:16 in KJV: verse → Strong's list → verify NT G-prefix."""
        if not KJV_AVAILABLE:
            pytest.skip("KJV JSON not available")
        from accordance_parser.bible_api import get_verse_strongs, BOOK_NAME_TO_ID

        book_id = BOOK_NAME_TO_ID.get("john")
        assert book_id is not None, "John not found in book name map"
        strongs = get_verse_strongs(book_id, 3, 16)
        assert strongs is not None, "John 3:16 should have Strong's data"
        assert len(strongs) >= 2, f"Expected >=2 Strong's numbers, got {len(strongs)}"
        assert all(s.startswith("G") for s in strongs), \
            f"NT verse should have G-prefix numbers, got: {strongs[:3]}"

    def test_strongs_index_builds_once(self):
        """The ensure_verse_strongs() function should be a no-op after first call."""
        if not KJV_AVAILABLE:
            pytest.skip("KJV JSON not available")
        # Re-import to test the module-level caching behavior
        from accordance_parser.bible_api import _ensure_verse_strongs, get_verse_strongs
        import time

        get_verse_strongs(1, 1, 1)  # prime the index
        t0 = time.time()
        _ensure_verse_strongs()  # second call should be instant
        elapsed = time.time() - t0
        # Should be very fast (just a dict check, no I/O)
        assert elapsed < 0.5, f"_ensure_verse_strongs() re-call took {elapsed:.3f}s"

    def test_strongs_index_on_nonexistent_verse(self):
        """Querying a non-existent verse returns None without error."""
        if not KJV_AVAILABLE:
            pytest.skip("KJV JSON not available")
        from accordance_parser.bible_api import get_verse_strongs

        result = get_verse_strongs(1, 999, 999)
        assert result is None, "Non-existent verse should return None"

    def test_strongs_index_chain_completeness(self):
        """End-to-end chain: verse → Strong's → another verse should be reachable."""
        if not KJV_AVAILABLE:
            pytest.skip("KJV JSON not available")
        from accordance_parser.bible_api import get_verse_strongs, BOOK_NAME_TO_ID

        gen_id = BOOK_NAME_TO_ID.get("genesis", 1)
        strongs_gen11 = get_verse_strongs(gen_id, 1, 1)
        assert strongs_gen11 is not None
        # Take the first Strong's number and look it up in the reverse index
        # (H7225 = "beginning" / "re'shiyth")
        target = strongs_gen11[0]

        # Build reverse index manually (same as _ensure_strongs_index logic)
        import json
        import re
        strongs_tag = re.compile(r"<S>(\d+)</S>")
        reverse_index: dict = {}
        with open(str(_KJV_PATH), "r", encoding="utf-8") as f:
            data = json.load(f)
        for verse in data:
            book = verse.get("book")
            ch = verse.get("chapter")
            vs = verse.get("verse")
            text = verse.get("text", "")
            if book is None:
                continue
            for n in strongs_tag.findall(text):
                prefix = "H" if book <= 39 else "G"
                key = f"{prefix}{int(n):04d}"
                reverse_index.setdefault(key, []).append((book, ch, vs))

        occurrences = reverse_index.get(target, [])
        assert len(occurrences) > 0, f"Strong's {target} should appear somewhere"
        # Verify Genesis 1:1 is in the occurrences
        assert (gen_id, 1, 1) in occurrences, \
            f"Strong's {target} should appear in Genesis 1:1"


# =============================================================================
# A1-2: NFC vs NFD Greek Normalization — Testable via strip_diacritics
# =============================================================================


class TestNFCDiacriticNormalization:
    """Verify strip_diacritics() produces identical output regardless of
    Unicode normalization form (NFC vs NFD)."""

    # Greek word λόγος: NFC (precomposed) and NFD (decomposed)
    LOGOS_NFC = "\u03bb\u03cc\u03b3\u03bf\u03c2"       # λόγος
    LOGOS_NFD = unicodedata.normalize("NFD", LOGOS_NFC)
    EXPECTED = "λογοσ"

    # Greek word ἀγάπη (agape) with rough breathing
    AGAPE_NFC = "\u1f00\u03b3\u03ac\u03c0\u03b7"        # ἀγάπη
    AGAPE_NFD = unicodedata.normalize("NFD", AGAPE_NFC)
    EXPECTED_AGAPE = "αγαπη"

    # Greek word with final sigma — πίστις
    PISTIS_NFC = "\u03c0\u03af\u03c3\u03c4\u03b9\u03c2"  # πίστις
    PISTIS_NFD = unicodedata.normalize("NFD", PISTIS_NFC)
    EXPECTED_PISTIS = "πιστισ"

    def test_strip_diacritics_logos_nfc_nfd_equal(self):
        """λόγος NFC and NFD forms must both strip to 'λογοσ'."""
        assert strip_diacritics(self.LOGOS_NFC) == self.EXPECTED
        assert strip_diacritics(self.LOGOS_NFD) == self.EXPECTED
        assert strip_diacritics(self.LOGOS_NFC) == strip_diacritics(self.LOGOS_NFD)

    def test_strip_diacritics_agape_nfc_nfd_equal(self):
        """ἀγάπη NFC and NFD forms must both strip to 'αγαπη'."""
        assert strip_diacritics(self.AGAPE_NFC) == self.EXPECTED_AGAPE
        assert strip_diacritics(self.AGAPE_NFD) == self.EXPECTED_AGAPE
        assert strip_diacritics(self.AGAPE_NFC) == strip_diacritics(self.AGAPE_NFD)

    def test_strip_diacritics_pistis_nfc_nfd_equal(self):
        """πίστις NFC and NFD must both strip to 'πιστισ' (final sigma → σ)."""
        result_nfc = strip_diacritics(self.PISTIS_NFC)
        result_nfd = strip_diacritics(self.PISTIS_NFD)
        assert result_nfc == self.EXPECTED_PISTIS
        assert result_nfd == self.EXPECTED_PISTIS
        assert result_nfc == result_nfd

    def test_strip_diacritics_hebrew_nfc_nfd_equal(self):
        """Hebrew בְּרֵאשִׁית NFC/NFC must both strip to 'בראשית'."""
        hebrew_nfc = "\u05d1\u05b0\u05bc\u05e8\u05b5\u05d0\u05e9\u05c1\u05b4\u05d9\u05ea"
        hebrew_nfd = unicodedata.normalize("NFD", hebrew_nfc)
        expected = "בראשית"
        assert strip_diacritics(hebrew_nfc) == expected
        assert strip_diacritics(hebrew_nfd) == expected

    def test_detect_language_greek_nfc_nfd(self):
        """detect_language() should return 'el' for both NFC and NFD Greek."""
        assert detect_language(self.LOGOS_NFC) == "el"
        assert detect_language(self.LOGOS_NFD) == "el"
        assert detect_language(self.AGAPE_NFC) == "el"

    def test_detect_language_hebrew(self):
        """detect_language() should return 'he' for Hebrew text."""
        assert detect_language("\u05d1\u05e8\u05d0\u05e9\u05d9\u05ea") == "he"
        assert detect_language("\u05d1\u05bc\u05b0\u05e8\u05b5\u05d0\u05e9\u05c1\u05b4\u05d9\u05ea") == "he"

    def test_detect_language_english(self):
        """detect_language() should return 'en' for English text."""
        assert detect_language("In the beginning God created") == "en"
        assert detect_language("John 3:16") == "en"

    def test_detect_language_empty(self):
        """detect_language() should return 'en' for empty/whitespace-only strings."""
        assert detect_language("") == "en"
        assert detect_language("   ") == "en"


# =============================================================================
# A1-3: Diacritic-Stripped Query Matching — Testable via SearchIndex
# =============================================================================


class TestDiacriticStrippedSearch:
    """Verify that searching with bare Greek (no diacritics) matches accented
    entries when using a temporary in-memory SearchIndex."""

    @pytest.fixture
    def index_with_greek(self):
        """Create a temporary SearchIndex with a few Greek entries."""
        # Monkey-patch _default_db_path to use tmpfile
        import accordance_parser.search_index as si_mod
        original = si_mod._default_db_path
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        si_mod._default_db_path = lambda: db_path
        try:
            idx = SearchIndex(db_path)
            conn = idx._ensure_conn()
            # Insert test entries with accented Greek
            entries = [
                ("lexicon", "TestLex", "λόγος", "el",
                 '"λόγος" — TestLex',
                 '"λόγος" — TestLex — λόγος word meaning reasoning',
                 '"λογοσ" — testlex — λογοσ word meaning reasoning', ""),
                ("lexicon", "TestLex", "ἀγάπη", "el",
                 '"ἀγάπη" — TestLex',
                 '"ἀγάπη" — TestLex — ἀγάπη love charity',
                 '"αγαπη" — testlex — αγαπη love charity', ""),
                ("lexicon", "TestLex", "πίστις", "el",
                 '"πίστις" — TestLex',
                 '"πίστις" — TestLex — πίστις faith belief',
                 '"πιστισ" — testlex — πιστισ faith belief', ""),
                ("lexicon", "TestLex", "χάρις", "el",
                 '"χάρις" — TestLex',
                 '"χάρις" — TestLex — χάρις grace favor',
                 '"χαρισ" — testlex — χαρισ grace favor', ""),
            ]
            conn.executemany(
                "INSERT INTO search_content (source_type, source_name, source_id, language, citation, body_text, body_norm, book) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
                entries,
            )
            conn.execute("INSERT INTO search_idx(search_idx) VALUES('rebuild')")
            conn.commit()
            yield idx
        finally:
            idx.close()
            si_mod._default_db_path = original
            Path(db_path).unlink(missing_ok=True)

    def test_bare_greek_matches_accented_logos(self, index_with_greek):
        """Searching 'λογοσ' (bare) should match 'λόγος' (accented) entry."""
        results = index_with_greek.search("λογος", "lexicon", "el", max_results=5, fuzzy=True)
        assert len(results) > 0, "Bare Greek search should return results"
        source_ids = [r[2] for r in results]
        assert "λόγος" in source_ids, f"Expected λόγος in results, got {source_ids}"

    def test_bare_greek_matches_accented_agape(self, index_with_greek):
        """Searching 'αγαπη' should match 'ἀγάπη' entry."""
        results = index_with_greek.search("αγαπη", "lexicon", "el", max_results=5, fuzzy=True)
        source_ids = [r[2] for r in results]
        assert "ἀγάπη" in source_ids, f"Expected ἀγάπη in results, got {source_ids}"

    def test_bare_greek_matches_pistis(self, index_with_greek):
        """Searching 'πιστισ' should match 'πίστις' entry."""
        results = index_with_greek.search("πιστισ", "lexicon", "el", max_results=5, fuzzy=True)
        source_ids = [r[2] for r in results]
        assert "πίστις" in source_ids, f"Expected πίστις in results, got {source_ids}"

    def test_bare_greek_matches_charis(self, index_with_greek):
        """Searching 'χαρισ' should match 'χάρις' entry."""
        results = index_with_greek.search("χαρισ", "lexicon", "el", max_results=5, fuzzy=True)
        source_ids = [r[2] for r in results]
        assert "χάρις" in source_ids, f"Expected χάρις in results, got {source_ids}"


# =============================================================================
# A1-4: Multi-Word Transliterated Query — Testable via transliteration functions
# =============================================================================


class TestTransliterationFunctions:
    """Directly test transliteration and reverse-transliteration pure functions."""

    # ── Greek → Latin ──

    def test_transliterate_greek_logos(self):
        """λόγος → 'logos'."""
        assert transliterate_greek("\u03bb\u03cc\u03b3\u03bf\u03c2") == "logos"

    def test_transliterate_greek_agape(self):
        """ἀγάπη → 'agapē' (smooth breathing ignored)."""
        result = transliterate_greek("\u1f00\u03b3\u03ac\u03c0\u03b7")
        assert result in ("agapē", "agape"), f"Unexpected: {result}"

    def test_transliterate_greek_pneuma(self):
        """πνεῦμα → 'pneuma'."""
        result = transliterate_greek("\u03c0\u03bd\u03b5\u1fe6\u03bc\u03b1")
        assert result in ("pneuma", "pneumā"), f"Unexpected: {result}"

    def test_transliterate_greek_rhema(self):
        """ῥῆμα → 'rhēma' (rough breathing + initial rho)."""
        result = transliterate_greek("\u1fe5\u1fc6\u03bc\u03b1")
        assert result in ("rhēma", "rhema"), f"Unexpected: {result}"

    def test_transliterate_greek_christos(self):
        """Χριστός → 'Christos' (chi → ch)."""
        result = transliterate_greek("\u03a7\u03c1\u03b9\u03c3\u03c4\u03cc\u03c2")
        assert result.lower() in ("christos", "chrestos"), f"Unexpected: {result}"

    # ── Hebrew → Latin ──

    def test_transliterate_hebrew_bereshit(self):
        """בְּרֵאשִׁית → should start with 'b'. Hebrew vowels stripped."""
        result = transliterate_hebrew(
            "\u05d1\u05b0\u05bc\u05e8\u05b5\u05d0\u05e9\u05c1\u05b4\u05d9\u05ea"
        )
        assert result.startswith("b"), f"Expected b-prefix, got: {result}"
        assert "r" in result
        assert "š" in result or "s" in result

    def test_transliterate_hebrew_torah(self):
        """תּוֹרָה → should start with 't'."""
        result = transliterate_hebrew(
            "\u05ea\u05bc\u05d5\u05b9\u05e8\u05b8\u05d4"
        )
        assert len(result) > 0
        assert result[0] in ("t", "ṯ"), f"Expected t/ṯ prefix, got: {result}"

    # ── Reverse transliteration ──

    def test_reverse_transliterate_greek_logos(self):
        """'logos' → should contain λογος in variants."""
        variants = reverse_transliterate_greek_variants("logos")
        assert len(variants) >= 1
        assert any("λο" in v and "γο" in v for v in variants), \
            f"Expected Greek letters from 'logos', got: {variants}"

    def test_reverse_transliterate_greek_agape(self):
        """'agape' → should generate variants with ε/η for 'e'."""
        variants = reverse_transliterate_greek_variants("agape")
        # 'e' is ambiguous → ε or η → at least 2 variants
        assert len(variants) >= 2, f"Expected >=2 variants for agape, got: {variants}"

    def test_reverse_transliterate_greek_ambiguous_vowels(self):
        """Both 'e' and 'o' create multiple Greek variants."""
        variants = reverse_transliterate_greek_variants("theos")
        assert len(variants) >= 4, \
            f"Expected >=4 variants for 'theos' (e/η × o/ω), got: {variants}"

    def test_reverse_transliterate_hebrew_shalom(self):
        """'shalom' → should produce Hebrew with שׁ and ל."""
        result = reverse_transliterate_hebrew("shalom")
        assert len(result) >= 2, f"Expected Hebrew chars, got: '{result}'"
        # Should contain shin (שׁ) and lamed (ל)
        assert "\u05e9" in result or "\u05c1" in result

    # ── Transliteration detection ──

    def test_is_likely_transliteration_greek(self):
        """Words with 'th', 'ph', 'ch', or 'ps' should be detected as Greek."""
        is_t, lang = Transliterator.classify("logos")
        assert is_t
        assert lang == "el"

        is_t, lang = Transliterator.classify("theos")
        assert is_t
        assert lang == "el"

        is_t, lang = Transliterator.classify("pneuma")
        assert is_t
        # pneuma has no marker in the heuristic; defaults to 'el'
        assert lang == "el"

    def test_is_likely_transliteration_hebrew(self):
        """Words with Hebrew-specific diacritics (ḥ, ṭ, ṣ, ʾ, ʿ) are non-ASCII
        and rejected by the ASCII-only gate. All-ASCII Hebrew-ish words
        (like 'bereshit') fall through to the default 'el' branch."""
        # Non-ASCII academic Hebrew transliteration → rejected (not ASCII)
        is_t, lang = Transliterator.classify("berēʾšît")
        assert not is_t, \
            "Non-ASCII Hebrew transliteration should be rejected by ASCII gate"

        # All-ASCII Hebrew-ish word → detected as transliteration with default 'el'
        is_t2, lang2 = Transliterator.classify("bereshit")
        assert is_t2
        # Default language for ambiguous ASCII is 'el' (Greek) — could be 'he' in future
        assert lang2 in ("el", "he")

    def test_is_likely_transliteration_english(self):
        """Plain English words should NOT be detected as transliteration."""
        is_t, lang = Transliterator.classify("salvation")
        assert is_t, f"Expected True (ambiguous defaults to el), got {is_t}"
        # "salvation" has 'th' in it? No. But ambiguous case defaults to True, 'el'
        # Let's use a word with no Greek markers
        is_t2, lang2 = Transliterator.classify("love")
        assert is_t2  # Ambiguous → True, 'el'

    def test_is_likely_transliteration_with_numbers(self):
        """Strings with digits should NOT be detected as transliteration."""
        is_t, lang = Transliterator.classify("logos123")
        assert not is_t

    def test_is_likely_transliteration_empty(self):
        """Empty string should not be transliteration."""
        is_t, lang = Transliterator.classify("")
        assert not is_t
        assert lang == ""

    def test_is_likely_transliteration_non_ascii(self):
        """Non-ASCII (actual Greek) should not be detected as transliteration."""
        is_t, lang = Transliterator.classify("λόγος")
        assert not is_t


# =============================================================================
# A1-5: Mixed-Language Query — Testable via detect_language
# =============================================================================


class TestMixedLanguageDetection:
    """Test detect_language() with mixed English-Greek and English-Hebrew strings."""

    def test_english_with_greek_word(self):
        """English sentence with one Greek word → still 'el' if Greek > 50%."""
        # Short English + Greek: "word λόγος" → 4 Latin + 5 Greek → Greek wins
        text = "word \u03bb\u03cc\u03b3\u03bf\u03c2"
        result = detect_language(text)
        assert result in ("el", "en"), f"Unexpected: {result}"

    def test_mostly_english_with_greek_word(self):
        """Lots of English text with one Greek word → 'en'."""
        text = "In the beginning was the \u03bb\u03cc\u03b3\u03bf\u03c2 and the word was"
        result = detect_language(text)
        assert result == "en", f"Expected 'en' for mostly English, got: {result}"

    def test_english_with_hebrew_word(self):
        """English with one short Hebrew word → still 'en' if Hebrew ≤ 50%."""
        text = "The word \u05d1\u05e8\u05d0\u05e9\u05d9\u05ea means beginning in Hebrew"
        result = detect_language(text)
        assert result == "en", f"Expected 'en' for mostly English, got: {result}"

    def test_greek_and_hebrew_mixed(self):
        """Mix of Greek and Hebrew: neither > 50% → 'en'."""
        # 5 Greek + 4 Hebrew → Greek 55%? Let's compute carefully.
        text = "\u03bb\u03cc\u03b3\u03bf\u03c2\u05d1\u05e8\u05d0"  # λόγוסברא
        result = detect_language(text)
        # Both could be present, but neither >50% → 'en'
        # Actually: logos = 5 chars, bera = 3 chars → 5/8 ≈ 62.5% Greek → 'el'
        assert result in ("el", "he", "en"), f"Unexpected: {result}"

    def test_mixed_english_hebrew_boundary(self):
        """Exactly 50/50 English-Hebrew → should be 'en' (not >50% Hebrew)."""
        # 3 Hebrew + 3 English = 50% Hebrew → not >50%, so 'en'
        text = "\u05d1\u05e8\u05d0" + "abc"  # 3 Hebrew, 3 ASCII
        result = detect_language(text)
        assert result == "en", f"50/50 should be 'en', got: {result}"


# =============================================================================
# A1-6: Index Status Reporting — Testable via SearchIndex.get_status()
# =============================================================================


class TestIndexStatus:
    """Create a SearchIndex instance and verify get_status() structured output."""

    @pytest.fixture
    def empty_index(self):
        """Create a temporary, empty SearchIndex."""
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        idx = SearchIndex(db_path)
        yield idx
        idx.close()
        Path(db_path).unlink(missing_ok=True)

    def test_get_status_returns_dict(self, empty_index):
        """get_status() should return a dict with expected keys."""
        status = empty_index.get_status()
        assert isinstance(status, dict)
        for key in ("db_path", "total_rows", "last_build", "sources",
                     "commentary_books", "bible_books"):
            assert key in status, f"Missing key: {key}"

    def test_get_status_empty_index(self, empty_index):
        """Empty index should report total_rows=0 and empty source dicts."""
        status = empty_index.get_status()
        assert status["total_rows"] == 0
        assert status["sources"] == {}
        assert status["commentary_books"] == []
        assert status["bible_books"] == []
        # last_build should be None for empty index
        assert status["last_build"] is None

    def test_get_status_db_path_matches(self, empty_index):
        """The reported db_path should match the actual path."""
        status = empty_index.get_status()
        assert status["db_path"] == empty_index.db_path

    def test_get_status_after_population(self):
        """After inserting rows, get_status() should reflect the new counts."""
        import accordance_parser.search_index as si_mod
        original = si_mod._default_db_path
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        si_mod._default_db_path = lambda: db_path
        try:
            idx = SearchIndex(db_path)
            conn = idx._ensure_conn()
            conn.executemany(
                "INSERT INTO search_content (source_type, source_name, source_id, language, citation, body_text, body_norm, book) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
                [
                    ("lexicon", "TestA", "e1", "el", "c1", "t1", "n1", ""),
                    ("lexicon", "TestA", "e2", "el", "c2", "t2", "n2", ""),
                    ("bible", "KJV", "Gen 1:1", "en", "c3", "t3", "n3", "Genesis"),
                ],
            )
            conn.execute("INSERT INTO search_idx(search_idx) VALUES('rebuild')")
            conn.commit()
            status = idx.get_status()
            assert status["total_rows"] == 3
            assert status["sources"]["lexicon"] == 2
            assert status["sources"]["bible"] == 1
            assert status["bible_books"] == ["Genesis"]
            assert status["last_build"] is None  # no index_meta entries
            idx.close()
        finally:
            si_mod._default_db_path = original
            Path(db_path).unlink(missing_ok=True)


# =============================================================================
# A1-7: Index Rebuild with force=True — Testable (create temp index)
# =============================================================================


class TestIndexRebuild:
    """Create a temp SearchIndex, add entries, force-rebuild, verify contents."""

    def test_force_rebuild_clears_all(self):
        """build_all(force=True) should clear all pre-existing data.

        After force-rebuild, JSON-sourced content (language texts, apparatus)
        may be re-indexed from the real data/ directory, so the table won't be
        empty.  But the pre-populated test data must be gone.
        """
        import accordance_parser.search_index as si_mod
        original = si_mod._default_db_path
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        si_mod._default_db_path = lambda: db_path
        try:
            idx = SearchIndex(db_path)
            conn = idx._ensure_conn()
            # Pre-populate with some data
            conn.execute(
                "INSERT INTO search_content (source_type, source_name, source_id, language, citation, body_text, body_norm, book) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
                ("lexicon", "TestLex", "e1", "el", "c1", "t1", "n1", ""),
            )
            conn.execute(
                "INSERT INTO index_meta VALUES (?, ?, ?, ?, ?)",
                ("lexicon", "TestLex", "e1", 1.0, 2.0),
            )
            conn.commit()
            conn.execute("INSERT INTO search_idx(search_idx) VALUES('rebuild')")
            conn.commit()

            assert conn.execute("SELECT COUNT(*) FROM search_content").fetchone()[0] == 1

            # Force rebuild — clears all pre-existing data, but JSON sources
            # (language texts, apparatus) may be re-indexed from data/.
            idx.build_all(force=True)
            test_remaining = conn.execute(
                "SELECT COUNT(*) FROM search_content WHERE source_name='TestLex'"
            ).fetchone()[0]
            assert test_remaining == 0, (
                f"Force rebuild should clear test data, got {test_remaining} rows"
            )
            # index_meta should not contain the test entry either
            meta_test = conn.execute(
                "SELECT COUNT(*) FROM index_meta WHERE source_name='TestLex'"
            ).fetchone()[0]
            assert meta_test == 0, "index_meta should not have TestLex after force rebuild"

            idx.close()
        finally:
            si_mod._default_db_path = original
            Path(db_path).unlink(missing_ok=True)

    def test_build_all_no_modules_does_not_error(self):
        """build_all with no module args should complete without error.

        JSON-sourced content (language texts, apparatus) may be indexed
        from the real data/ directory, so count may be > 0.
        """
        import accordance_parser.search_index as si_mod
        original = si_mod._default_db_path
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        si_mod._default_db_path = lambda: db_path
        try:
            idx = SearchIndex(db_path)
            count = idx.build_all()
            assert count >= 0
            idx.close()
        finally:
            si_mod._default_db_path = original
            Path(db_path).unlink(missing_ok=True)


# =============================================================================
# A1-8: Empty/Single-Char/Very-Long Queries — Testable via SearchIndex.search()
# =============================================================================


class TestBoundaryQueries:
    """Test edge-case queries on a temporary SearchIndex."""

    @pytest.fixture
    def populated_index(self):
        """Create a temp SearchIndex with a few English entries."""
        import accordance_parser.search_index as si_mod
        original = si_mod._default_db_path
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        si_mod._default_db_path = lambda: db_path
        idx = None
        try:
            idx = SearchIndex(db_path)
            conn = idx._ensure_conn()
            entries = [
                ("bible", "KJVS", "Genesis 1:1", "en",
                 "Genesis 1:1 (KJVS)",
                 "Genesis 1:1 (KJVS) — In the beginning God created the heaven and the earth",
                 "genesis 1:1 (kjvs) — in the beginning god created the heaven and the earth",
                 "Genesis"),
                ("bible", "KJVS", "John 3:16", "en",
                 "John 3:16 (KJVS)",
                 "John 3:16 (KJVS) — For God so loved the world that he gave his only begotten Son",
                 "john 3:16 (kjvs) — for god so loved the world that he gave his only begotten son",
                 "John"),
            ]
            conn.executemany(
                "INSERT INTO search_content (source_type, source_name, source_id, language, citation, body_text, body_norm, book) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
                entries,
            )
            conn.execute("INSERT INTO search_idx(search_idx) VALUES('rebuild')")
            conn.commit()
            yield idx
        finally:
            if idx:
                idx.close()
            si_mod._default_db_path = original
            Path(db_path).unlink(missing_ok=True)

    def test_empty_query_returns_results_or_graceful(self, populated_index):
        """Empty query should not crash; may return results or empty list."""
        try:
            results = populated_index.search("", "all", "en", max_results=5, fuzzy=False)
            assert isinstance(results, list)
        except Exception:
            pytest.skip("Empty query rejected by FTS5 (acceptable behavior)")

    def test_single_char_query(self, populated_index):
        """Single-character query should not crash."""
        results = populated_index.search("a", "all", "en", max_results=5, fuzzy=False)
        assert isinstance(results, list)

    def test_very_long_query(self, populated_index):
        """Very long query string should not crash the index."""
        long_query = "God " * 200
        results = populated_index.search(long_query.strip(), "all", "en", max_results=5, fuzzy=False)
        assert isinstance(results, list)

    def test_query_with_special_characters(self, populated_index):
        """Query with FTS5 special characters (quotes) should be handled safely."""
        results = populated_index.search('God "created"', "all", "en", max_results=5, fuzzy=False)
        assert isinstance(results, list)

    def test_normal_query_returns_result(self, populated_index):
        """A normal query like 'beginning' should find Genesis 1:1."""
        results = populated_index.search("beginning", "bible", "en", max_results=5, fuzzy=False)
        assert len(results) >= 1, f"Expected at least 1 result for 'beginning', got {len(results)}"
        source_ids = [r[2] for r in results]
        assert "Genesis 1:1" in source_ids, f"Expected Genesis 1:1 in results, got {source_ids}"


# =============================================================================
# A1-9: Search Result Deduplication — Testable via search results
# =============================================================================


class TestSearchDeduplication:
    """Verify search_transliterated() doesn't return duplicate (source_name, source_id) pairs."""

    @pytest.fixture
    def index_with_greek(self):
        """Temp index with Greek entries that could produce duplicates."""
        import accordance_parser.search_index as si_mod
        original = si_mod._default_db_path
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        si_mod._default_db_path = lambda: db_path
        idx = None
        try:
            idx = SearchIndex(db_path)
            conn = idx._ensure_conn()
            entries = [
                ("lexicon", "BDAG", "\u03bb\u03cc\u03b3\u03bf\u03c2", "el",
                 '"\u03bb\u03cc\u03b3\u03bf\u03c2" \u2014 BDAG',
                 '"\u03bb\u03cc\u03b3\u03bf\u03c2" \u2014 BDAG \u2014 \u03bb\u03cc\u03b3\u03bf\u03c2 word reason account',
                 '"\u03bb\u03bf\u03b3\u03bf\u03c2" \u2014 bdag \u2014 \u03bb\u03bf\u03b3\u03bf\u03c2 word reason account',
                 ""),
                ("lexicon", "BDAG", "\u1f00\u03b3\u03ac\u03c0\u03b7", "el",
                 '"\u1f00\u03b3\u03ac\u03c0\u03b7" \u2014 BDAG',
                 '"\u1f00\u03b3\u03ac\u03c0\u03b7" \u2014 BDAG \u2014 \u1f00\u03b3\u03ac\u03c0\u03b7 love',
                 '"\u03b1\u03b3\u03b1\u03c0\u03b7" \u2014 bdag \u2014 \u03b1\u03b3\u03b1\u03c0\u03b7 love',
                 ""),
            ]
            conn.executemany(
                "INSERT INTO search_content (source_type, source_name, source_id, language, citation, body_text, body_norm, book) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
                entries,
            )
            conn.execute("INSERT INTO search_idx(search_idx) VALUES('rebuild')")
            conn.commit()
            yield idx
        finally:
            if idx:
                idx.close()
            si_mod._default_db_path = original
            Path(db_path).unlink(missing_ok=True)

    def test_search_transliterated_no_duplicates(self, index_with_greek):
        """Each result should have a unique (source_name, source_id) pair."""
        # Calling with Greek chars directly — search_transliterated will
        # see non-ASCII → not a transliteration → fall through to search()
        # So let's test with the transliterated form explicitly.
        results = index_with_greek.search_transliterated(
            "logos", source_type="lexicon", language="el", max_results=10
        )
        seen: set[tuple[str, str]] = set()
        for r in results:
            key = (r[1], r[2])  # (source_name, source_id)
            assert key not in seen, \
                f"Duplicate result: {r[1]}:{r[2]} appears more than once"
            seen.add(key)

    def test_search_deduplication(self, index_with_greek):
        """Plain search() also should not return duplicate rowids."""
        results = index_with_greek.search("αγαπη", "lexicon", "el", max_results=10, fuzzy=True)
        seen_keys: set[tuple[str, str]] = set()
        for r in results:
            key = (r[1], r[2])
            assert key not in seen_keys, \
                f"Duplicate (source_name, source_id): {key}"
            seen_keys.add(key)


# =============================================================================
# A1-10: Concurrent Search During Rebuild — Testable via threading
# =============================================================================


class TestConcurrentSearchDuringRebuild:
    """Prove WAL isolation works: searches don't block during rebuilds."""

    def test_concurrent_read_during_write(self):
        """Spawn a reader thread while writing in the main thread."""
        import accordance_parser.search_index as si_mod
        original = si_mod._default_db_path
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        si_mod._default_db_path = lambda: db_path
        idx = None
        try:
            idx = SearchIndex(db_path)
            conn = idx._ensure_conn()
            # Insert some initial data
            for i in range(100):
                conn.execute(
                    "INSERT INTO search_content (source_type, source_name, source_id, language, citation, body_text, body_norm, book) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
                    ("lexicon", f"Lex{i:03d}", f"entry{i:04d}", "el",
                     f"c{i}", f"text {i} " * 5, f"text {i} " * 5, ""),
                )
            conn.execute("INSERT INTO search_idx(search_idx) VALUES('rebuild')")
            conn.commit()

            errors: list[Exception] = []
            search_success = threading.Event()

            def reader():
                conn2 = idx._ensure_conn()
                try:
                    for _ in range(20):
                        # Simple read during write
                        rows = conn2.execute(
                            "SELECT COUNT(*) FROM search_content"
                        ).fetchone()
                        assert rows is not None
                    search_success.set()
                except Exception as e:
                    errors.append(e)

            reader_thread = threading.Thread(target=reader, daemon=True)
            reader_thread.start()

            # Write in main thread while reader runs
            for i in range(50):
                idx._ensure_conn().execute(
                    "INSERT INTO search_content (source_type, source_name, source_id, language, citation, body_text, body_norm, book) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
                    ("lexicon", "Concurrent", f"c{i:04d}", "el",
                     f"cc{i}", f"concurrent {i}", f"concurrent {i}", ""),
                )

            reader_thread.join(timeout=5.0)

            if errors:
                pytest.fail(f"Reader thread errors: {errors}")
            assert search_success.is_set(), "Reader thread did not complete successfully"

            idx.close()
        finally:
            si_mod._default_db_path = original
            Path(db_path).unlink(missing_ok=True)


# =============================================================================
# A1-11: Strong's Across Translations — Testable via KJV JSON
# =============================================================================


class TestStrongsAcrossTranslations:
    """Verify get_verse_strongs() returns consistent Strong's for known verses."""

    def test_genesis_1_1_strongs_known(self):
        """Genesis 1:1 should include H7225 (re'shiyth/beginning) and H0430 (elohim/God)."""
        if not KJV_AVAILABLE:
            pytest.skip("KJV JSON not available")
        from accordance_parser.bible_api import get_verse_strongs, BOOK_NAME_TO_ID

        gen_id = BOOK_NAME_TO_ID.get("genesis")
        assert gen_id is not None
        strongs = get_verse_strongs(gen_id, 1, 1)
        assert strongs is not None
        # Known Strong's numbers for Genesis 1:1 in KJV
        # H7225 = beginning, H1254 = created, H0430 = God, H8064 = heaven, H0776 = earth
        known = {"H7225", "H1254", "H0430", "H8064", "H0776"}
        found = set(strongs)
        assert len(found & known) >= 3, \
            f"Expected at least 3 of {known} in Genesis 1:1, got: {found}"

    def test_john_3_16_strongs_known(self):
        """John 3:16 should include G2316 (theos/God) and G2889 (kosmos/world)."""
        if not KJV_AVAILABLE:
            pytest.skip("KJV JSON not available")
        from accordance_parser.bible_api import get_verse_strongs, BOOK_NAME_TO_ID

        john_id = BOOK_NAME_TO_ID.get("john")
        assert john_id is not None
        strongs = get_verse_strongs(john_id, 3, 16)
        assert strongs is not None
        # G2316 = God, G2889 = world, G0025 = loved, G3439 = only-begotten
        known = {"G2316", "G2889", "G0025", "G5207", "G3439"}
        found = set(strongs)
        assert len(found & known) >= 2, \
            f"Expected at least 2 of {known} in John 3:16, got: {found}"

    def test_psalm_23_1_strongs(self):
        """Psalm 23:1 should have H-prefix Strong's numbers."""
        if not KJV_AVAILABLE:
            pytest.skip("KJV JSON not available")
        from accordance_parser.bible_api import get_verse_strongs, BOOK_NAME_TO_ID

        psalm_id = BOOK_NAME_TO_ID.get("psalms")
        assert psalm_id is not None
        strongs = get_verse_strongs(psalm_id, 23, 1)
        assert strongs is not None
        assert len(strongs) >= 1
        assert all(s.startswith("H") for s in strongs), \
            f"OT Psalms should have H-prefix, got: {strongs}"

    def test_romans_1_16_strongs(self):
        """Romans 1:16 (NT) should have G-prefix Strong's numbers."""
        if not KJV_AVAILABLE:
            pytest.skip("KJV JSON not available")
        from accordance_parser.bible_api import get_verse_strongs, BOOK_NAME_TO_ID

        rom_id = BOOK_NAME_TO_ID.get("romans")
        assert rom_id is not None
        strongs = get_verse_strongs(rom_id, 1, 16)
        assert strongs is not None
        assert len(strongs) >= 1
        assert all(s.startswith("G") for s in strongs), \
            f"NT Romans should have G-prefix, got: {strongs}"

    def test_strongs_zero_padded_format(self):
        """All Strong's numbers should follow the pattern HXXXX or GXXXX (4 digits)."""
        if not KJV_AVAILABLE:
            pytest.skip("KJV JSON not available")
        from accordance_parser.bible_api import get_verse_strongs, BOOK_NAME_TO_ID
        import re

        gen_id = BOOK_NAME_TO_ID.get("genesis", 1)
        strongs = get_verse_strongs(gen_id, 1, 1)
        assert strongs is not None
        pattern = re.compile(r"^[HG]\d{4}$")
        for s in strongs:
            assert pattern.match(s), f"Strong's {s} doesn't match H/G0000 format"


# =============================================================================
# A1-12: Unicode Extreme Characters — Testable via strip_diacritics + search
# =============================================================================


class TestUnicodeExtremeCharacters:
    """Test stripping of RTL overrides, zero-width chars, stacked combining marks."""

    def test_rtl_override_stripped(self):
        """Text with RTL override (U+202E) should be stripped/handled gracefully."""
        rtl_override = "\u202E"  # RIGHT-TO-LEFT OVERRIDE
        text = rtl_override + "\u03bb\u03cc\u03b3\u03bf\u03c2"  # λόγος
        result = strip_diacritics(text)
        # The RTL override is not a combining char, so it stays - but the
        # important thing is that Greek diacritics ARE stripped
        assert "λογοσ" in result or "\u03bb" in result, \
            f"Greek chars should survive: got '{unicodedata.name(text[0], '?')}'"

    def test_lrm_rlm_characters(self):
        """Left-to-right and right-to-left marks should be ignored by diacritic stripping."""
        lrm = "\u200E"  # LEFT-TO-RIGHT MARK
        rlm = "\u200F"  # RIGHT-TO-LEFT MARK
        text = f"{lrm}\u03bb\u03cc\u03b3\u03bf\u03c2{rlm}"  # λόγος with bidirectional marks
        result = strip_diacritics(text)
        # LRM/RLM are not combining chars, so they stay; Greek diacritics stripped
        # At minimum, the function should not crash and output should still contain base chars
        assert len(result) > 0, "strip_diacritics should not return empty for valid input"

    def test_zero_width_characters(self):
        """Zero-width joiner/non-joiner should not crash strip_diacritics."""
        zwj = "\u200D"  # ZERO WIDTH JOINER
        zwnj = "\u200C"  # ZERO WIDTH NON-JOINER
        text = f"{zwj}\u03bb\u03cc\u03b3\u03bf\u03c2{zwnj}"  # λόγος
        result = strip_diacritics(text)
        assert len(result) > 0

    def test_stacked_combining_marks(self):
        """Multiple combining marks stacked on a single base char should all be stripped."""
        # Base Greek alpha with acute accent + macron + diaeresis
        alpha = "\u03B1"  # α
        acute = "\u0301"  # combining acute
        macron = "\u0304"  # combining macron
        diaeresis = "\u0308"  # combining diaeresis
        stacked = alpha + acute + macron + diaeresis
        result = strip_diacritics(stacked)
        assert result == "\u03B1", \
            f"All combining marks should be stripped, got: {repr(result)}"

    def test_stacked_combining_on_hebrew(self):
        """Multiple niqqud marks stacked on Hebrew should all be stripped."""
        # Bet + dagesh + sheva
        bet = "\u05D1"     # ב
        dagesh = "\u05BC"  # DAGESH
        sheva = "\u05B0"   # SHEVA
        stacked = bet + dagesh + sheva
        result = strip_diacritics(stacked)
        assert result == "\u05D1", \
            f"All combining marks should be stripped, got: {repr(result)}"

    def test_full_polytonic_greek(self):
        """Heavily accented polytonic Greek should strip cleanly."""
        # πολυτονικόν — full polytonic accents
        polytonic = "\u03c0\u03bf\u03bb\u03c5\u03c4\u03bf\u03bd\u03b9\u03ba\u03cc\u03bd"
        result = strip_diacritics(polytonic)
        # Should only contain base Greek letters, no combining marks
        for ch in result:
            assert not unicodedata.combining(ch), \
                f"Combining char {unicodedata.name(ch, '?')} U+{ord(ch):04X} found in result"

    def test_language_detection_extreme_chars(self):
        """detect_language should handle strings with unusual Unicode marks."""
        text_with_zw = "\u200D\u200C" + "\u03bb\u03cc\u03b3\u03bf\u03c2"  # ZWJ+ZWNJ + λόγος
        result = detect_language(text_with_zw)
        # λόγος is 5 Greek chars, ZWJ/ZWNJ are not alphabetic
        assert result in ("el", "en"), f"Unexpected: {result}"

    def test_transliterate_greek_extreme_accents(self):
        """Transliteration should handle heavily accented polytonic Greek."""
        # Rough breathing + acute + iota subscript: ᾅ (alpha with dasia, oxia, ypogegrammeni)
        text = "\u1F05"  # ἅ
        result = transliterate_greek(text)
        # Should be 'ha' (rough breathing + alpha)
        assert "h" in result.lower() and "a" in result.lower(), \
            f"Expected 'ha' from U+1F05 (alpha+rough+acute), got: {result}"

    def test_strip_diacritics_preserves_ascii(self):
        """strip_diacritics should not touch pure ASCII text."""
        text = "hello world 123"
        assert strip_diacritics(text) == text


# =============================================================================
# Additional validation tests
# =============================================================================


class TestSearchIndexMisc:
    """Miscellaneous SearchIndex integration tests."""

    def test_temp_index_creation(self):
        """SearchIndex should be creatable with a temp file path."""
        import accordance_parser.search_index as si_mod
        original = si_mod._default_db_path
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        si_mod._default_db_path = lambda: db_path
        try:
            idx = SearchIndex(db_path)
            assert idx.db_path == db_path
            count = idx.ensure_index()
            assert count == 0
            idx.close()
        finally:
            si_mod._default_db_path = original
            Path(db_path).unlink(missing_ok=True)

    def test_close_and_reopen(self):
        """Index should be closeable and re-openable without error."""
        import accordance_parser.search_index as si_mod
        original = si_mod._default_db_path
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        si_mod._default_db_path = lambda: db_path
        try:
            idx = SearchIndex(db_path)
            idx.close()
            # Re-open (creates a new internal connection)
            idx2 = SearchIndex(db_path)
            assert idx2.ensure_index() == 0
            idx2.close()
        finally:
            si_mod._default_db_path = original
            Path(db_path).unlink(missing_ok=True)

    def test_transliterate_hebrew_returns_string(self):
        """transliterate_hebrew() should always return a string."""
        result = transliterate_hebrew("")
        assert isinstance(result, str)

    def test_transliterate_greek_returns_string(self):
        """transliterate_greek() should always return a string."""
        result = transliterate_greek("")
        assert isinstance(result, str)

    def test_reverse_transliterate_greek_returns_variants(self):
        """reverse_transliterate_greek() returns a single string variant."""
        result = reverse_transliterate_greek("logos")
        assert isinstance(result, str)
        assert len(result) > 0

    def test_strip_diacritics_normalizes_final_sigma(self):
        """strip_diacritics should convert final sigma (ς) to regular sigma (σ)."""
        result = strip_diacritics("\u03c2")  # ς alone
        assert result == "\u03c3", f"Final sigma should become regular sigma, got: {repr(result)}"


# =============================================================================
# FTS5 Rebuild Equivalence
# =============================================================================


class TestFTS5RebuildEquivalence:
    """Verify that rebuilding the FTS5 index produces identical search results.

    Sequence: build → search → record results → force-rebuild → search → assert identical.
    """

    @pytest.fixture
    def populated_index(self):
        """Create a temp SearchIndex with a mix of bible and lexicon entries."""
        import accordance_parser.search_index as si_mod
        original = si_mod._default_db_path
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        si_mod._default_db_path = lambda: db_path
        idx = None
        try:
            idx = SearchIndex(db_path)
            conn = idx._ensure_conn()
            entries = [
                # English bible entry
                ("bible", "ESV", "Genesis 1:1", "en",
                 "Genesis 1:1 (ESV)",
                 "Genesis 1:1 (ESV) — In the beginning God created the heavens and the earth",
                 "genesis 1:1 (esv) — in the beginning god created the heavens and the earth",
                 "Genesis"),
                # English bible entry with "word"
                ("bible", "ESV", "John 1:1", "en",
                 "John 1:1 (ESV)",
                 "John 1:1 (ESV) — In the beginning was the Word, and the Word was with God",
                 "john 1:1 (esv) — in the beginning was the word, and the word was with god",
                 "John"),
                # Greek lexicon entry
                ("lexicon", "BDAG", "\u03bb\u03cc\u03b3\u03bf\u03c2", "el",
                 '"\u03bb\u03cc\u03b3\u03bf\u03c2" \u2014 BDAG',
                 '"\u03bb\u03cc\u03b3\u03bf\u03c2" \u2014 BDAG \u2014 \u03bb\u03cc\u03b3\u03bf\u03c2 word reason account',
                 '"\u03bb\u03bf\u03b3\u03bf\u03c2" \u2014 bdag \u2014 \u03bb\u03bf\u03b3\u03bf\u03c2 word reason account',
                 ""),
                # Greek lexicon entry
                ("lexicon", "BDAG", "\u1f00\u03b3\u03ac\u03c0\u03b7", "el",
                 '"\u1f00\u03b3\u03ac\u03c0\u03b7" \u2014 BDAG',
                 '"\u1f00\u03b3\u03ac\u03c0\u03b7" \u2014 BDAG \u2014 \u1f00\u03b3\u03ac\u03c0\u03b7 love charity',
                 '"\u03b1\u03b3\u03b1\u03c0\u03b7" \u2014 bdag \u2014 \u03b1\u03b3\u03b1\u03c0\u03b7 love charity',
                 ""),
            ]
            conn.executemany(
                "INSERT INTO search_content (source_type, source_name, source_id, language, citation, body_text, body_norm, book) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
                entries,
            )
            # Initial FTS5 rebuild
            conn.execute("INSERT INTO search_idx(search_idx) VALUES('rebuild')")
            conn.commit()
            yield idx
        finally:
            if idx:
                idx.close()
            si_mod._default_db_path = original
            Path(db_path).unlink(missing_ok=True)

    def test_fts5_rebuild_identical_results(self, populated_index):
        """After FTS5 rebuild, the same queries return identical result tuples."""
        # --- Run searches BEFORE rebuild ---
        results1_before = populated_index.search(
            "beginning", source_type="all", language="en", max_results=10, fuzzy=False
        )
        results2_before = populated_index.search(
            "λογος", source_type="lexicon", language="el", max_results=10, fuzzy=True
        )
        results3_before = populated_index.search(
            "word", source_type="all", language="any", max_results=10, fuzzy=False
        )

        # --- Force rebuild ---
        conn = populated_index._ensure_conn()
        conn.execute("INSERT INTO search_idx(search_idx) VALUES('rebuild')")
        conn.commit()

        # --- Run the SAME searches AFTER rebuild ---
        results1_after = populated_index.search(
            "beginning", source_type="all", language="en", max_results=10, fuzzy=False
        )
        results2_after = populated_index.search(
            "λογος", source_type="lexicon", language="el", max_results=10, fuzzy=True
        )
        results3_after = populated_index.search(
            "word", source_type="all", language="any", max_results=10, fuzzy=False
        )

        # --- Assert identical ---
        assert results1_before == results1_after, (
            f"Query 'beginning' (en, all, non-fuzzy) changed after rebuild.\n"
            f"Before: {results1_before}\nAfter:  {results1_after}"
        )
        assert results2_before == results2_after, (
            f"Query 'λογος' (el, lexicon, fuzzy) changed after rebuild.\n"
            f"Before: {results2_before}\nAfter:  {results2_after}"
        )
        assert results3_before == results3_after, (
            f"Query 'word' (any, all, non-fuzzy) changed after rebuild.\n"
            f"Before: {results3_before}\nAfter:  {results3_after}"
        )

    def test_fts5_rebuild_identical_row_count(self, populated_index):
        """After FTS5 rebuild, each query returns the same number of rows."""
        conn = populated_index._ensure_conn()

        def _count(query, source_type, lang, fuzzy):
            return len(populated_index.search(
                query, source_type=source_type, language=lang, max_results=50, fuzzy=fuzzy
            ))

        queries = [
            ("beginning", "all", "en", False),
            ("λογος", "lexicon", "el", True),
            ("word", "all", "any", False),
            ("love", "lexicon", "el", True),
        ]

        counts_before = [_count(*q) for q in queries]

        # Force rebuild
        conn.execute("INSERT INTO search_idx(search_idx) VALUES('rebuild')")
        conn.commit()

        counts_after = [_count(*q) for q in queries]

        for i, q in enumerate(queries):
            assert counts_before[i] == counts_after[i], (
                f"Query {q} count changed: {counts_before[i]} → {counts_after[i]}"
            )


# =============================================================================
# A1-13: Concurrent _build_index_background + search_all
# =============================================================================


class TestConcurrentBuildAndSearch:
    """Start background build, fire concurrent search_all calls; assert no crashes
    and consistent results after the build completes."""

    def test_background_build_concurrent_search_all(self):
        import accordance_mcp_server as mcp
        import accordance_parser.search_index as si_mod
        import time

        # -- Create a temp SearchIndex with pre-populated data -----------------
        si_orig = si_mod._default_db_path
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        si_mod._default_db_path = lambda: db_path

        idx = None
        try:
            idx = SearchIndex(db_path)
            conn = idx._ensure_conn()
            entries = [
                ("bible", "ESV", "Genesis 1:1", "en",
                 "Genesis 1:1 (ESV)",
                 "Genesis 1:1 (ESV) — In the beginning God created the heavens and the earth",
                 "genesis 1:1 (esv) — in the beginning god created the heavens and the earth",
                 "Genesis"),
                ("bible", "ESV", "John 1:1", "en",
                 "John 1:1 (ESV)",
                 "John 1:1 (ESV) — In the beginning was the Word, and the Word was with God",
                 "john 1:1 (esv) — in the beginning was the word, and the word was with god",
                 "John"),
                ("lexicon", "BDAG", "λόγος", "el",
                 '"λόγος" — BDAG',
                 '"λόγος" — BDAG — λόγος word reason account',
                 '"λογοσ" — bdag — λογοσ word reason account',
                 ""),
            ]
            conn.executemany(
                "INSERT INTO search_content (source_type, source_name, source_id, language, citation, body_text, body_norm, book) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
                entries,
            )
            conn.execute("INSERT INTO search_idx(search_idx) VALUES('rebuild')")
            conn.commit()
            idx.close()

            # Re-open so mcp can use its own connection
            temp_index = SearchIndex(db_path)

            # -- Save original module globals ---------------------------------
            orig_search_index = mcp._search_index
            orig_building = mcp._index_building
            orig_error = mcp._index_build_error
            orig_text = mcp._text_modules
            orig_lex = mcp._lexicon_modules
            orig_comm = mcp._commentary_modules

            try:
                # -- Patch module globals to use temp environment --------------
                mcp._search_index = temp_index
                mcp._text_modules = {}
                mcp._lexicon_modules = {}
                mcp._commentary_modules = {}
                mcp._index_building = False
                mcp._index_build_error = None

                # Synchronization primitives so search threads can reliably
                # hit both the building and post-build states.
                build_started = threading.Event()
                build_done = threading.Event()

                def _simulated_build():
                    mcp._index_building = True
                    build_started.set()
                    time.sleep(0.5)
                    mcp._index_building = False
                    build_done.set()

                # -- Start background build in a daemon thread -----------------
                build_thread = threading.Thread(
                    target=_simulated_build, daemon=True
                )
                build_thread.start()

                # -- Fire concurrent search_all calls --------------------------
                errors: list[str] = []
                result_slots: list[list[str]] = [[None] for _ in range(5)]

                def _search_worker(query, slot, errors_out):
                    try:
                        # Wait until the build has definitely started so we
                        # catch the _index_building=True state.
                        build_started.wait()
                        r = mcp.search_all(
                            query, source_type="all", language="en", max_results=10
                        )
                        slot[0] = r
                    except Exception as e:
                        errors_out.append(f"{type(e).__name__}: {e}")

                def _search_worker_after(barrier, query, slot, errors_out):
                    """Search worker that waits for the build to finish first."""
                    try:
                        barrier.wait()
                        r = mcp.search_all(
                            query, source_type="all", language="en", max_results=10
                        )
                        slot[0] = r
                    except Exception as e:
                        errors_out.append(f"{type(e).__name__}: {e}")

                threads = []
                # Threads 0-2: fire as soon as build starts (may see "still building")
                for i in range(3):
                    t = threading.Thread(
                        target=_search_worker,
                        args=("beginning", result_slots[i], errors),
                    )
                    threads.append(t)
                    t.start()

                # Threads 3-4: wait until build completes, then search
                # 3 parties: main thread + 2 worker threads
                after_barrier = threading.Barrier(3, timeout=5)
                for i in range(3, 5):
                    t = threading.Thread(
                        target=_search_worker_after,
                        args=(after_barrier, "beginning", result_slots[i], errors),
                    )
                    threads.append(t)
                    t.start()

                # Wait for build to complete, then release the after-barrier
                build_done.wait()
                after_barrier.wait()

                for t in threads:
                    t.join(timeout=5.0)

                build_thread.join(timeout=5.0)

                # -- Assert no crashes ----------------------------------------
                assert not errors, f"Errors during concurrent search: {errors}"

                # -- Collect "building" and real results ----------------------
                building_results = [
                    r[0] for r in result_slots
                    if r[0] is not None and "still building" in r[0].lower()
                ]
                real_results = [
                    r[0] for r in result_slots
                    if r[0] is not None and "still building" not in r[0].lower()
                ]

                # At least one thread should have seen the build-in-progress state
                assert len(building_results) >= 1, (
                    "Expected at least one search to see building-in-progress, "
                    "but all returned real results"
                )

                # Threads 3-4 should have returned real results post-build
                assert len(real_results) >= 1, (
                    "Expected at least one search result after build, "
                    f"got all-building: {[r[0][:80] if r[0] else None for r in result_slots]}"
                )

                # -- Assert consistent results --------------------------------
                # All post-build results for the same query should be identical
                if len(real_results) >= 2:
                    first = real_results[0]
                    for i, r in enumerate(real_results[1:], start=2):
                        assert r == first, (
                            f"Post-build thread returned different results:\n"
                            f"First:  {first[:200]}...\n"
                            f"Other:  {r[:200]}..."
                        )

            finally:
                # -- Restore originals ----------------------------------------
                mcp._search_index = orig_search_index
                mcp._index_building = orig_building
                mcp._index_build_error = orig_error
                mcp._text_modules = orig_text
                mcp._lexicon_modules = orig_lex
                mcp._commentary_modules = orig_comm
                temp_index.close()

        finally:
            si_mod._default_db_path = si_orig
            Path(db_path).unlink(missing_ok=True)


# =============================================================================
# _insert_batch fallback: warnings + failure count
# =============================================================================


class TestBatchInsertFallback:
    """Test that _insert_batch fallback path logs warnings and returns failure count."""

    def test_batch_insert_fallback_logs_warnings(self, caplog):
        """When batch insert fails, row-by-row fallback logs warnings for bad rows
        and returns the number of failures."""
        import logging
        import accordance_parser.search_index as si_mod

        original = si_mod._default_db_path
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        si_mod._default_db_path = lambda: db_path
        try:
            idx = SearchIndex(db_path)
            conn = idx._ensure_conn()

            # Row with source_name=None violates NOT NULL — place it first
            # so executemany fails before committing any row.
            batch = [
                ("lexicon", None, "bad1", "el", "c1", "t1", "n1", ""),
                ("lexicon", "TestA", "good1", "el", "c2", "t2", "n2", ""),
                ("lexicon", "TestA", "good2", "el", "c3", "t3", "n3", ""),
            ]

            with caplog.at_level(logging.WARNING, logger="accordance_parser.search_index"):
                failed = idx._insert_batch(conn, batch)

            assert failed == 1, f"Expected 1 failure, got {failed}"
            assert len(caplog.records) >= 1, "Expected at least 1 warning log record"
            assert "Failed to insert row" in caplog.text

            # Valid rows should have been inserted by the fallback path
            count = conn.execute("SELECT COUNT(*) FROM search_content").fetchone()[0]
            assert count == 2, f"Expected 2 inserted rows, got {count}"

            # Verify the bad row was NOT inserted
            bad = conn.execute(
                "SELECT COUNT(*) FROM search_content WHERE source_id = 'bad1'"
            ).fetchone()[0]
            assert bad == 0, "Bad row should not have been inserted"

            idx.close()
        finally:
            si_mod._default_db_path = original
            Path(db_path).unlink(missing_ok=True)

    def test_batch_insert_all_valid_returns_zero(self):
        """When all rows are valid, _insert_batch returns 0."""
        import accordance_parser.search_index as si_mod

        original = si_mod._default_db_path
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tf:
            db_path = tf.name
        si_mod._default_db_path = lambda: db_path
        try:
            idx = SearchIndex(db_path)
            conn = idx._ensure_conn()

            batch = [
                ("lexicon", "TestA", "e1", "el", "c1", "t1", "n1", ""),
                ("lexicon", "TestA", "e2", "el", "c2", "t2", "n2", ""),
            ]

            failed = idx._insert_batch(conn, batch)
            assert failed == 0, f"Expected 0 failures, got {failed}"

            count = conn.execute("SELECT COUNT(*) FROM search_content").fetchone()[0]
            assert count == 2

            idx.close()
        finally:
            si_mod._default_db_path = original
            Path(db_path).unlink(missing_ok=True)


# =============================================================================
# run_module-level test (for manual testing: pytest -v test_search_index.py)
# =============================================================================

if __name__ == "__main__":
    pytest.main([__file__, "-v", "--tb=short"])
