"""
Bible API - Verse text + Strong's numbers via bolls.life API and local KJV JSON.

Zero external dependencies. Uses stdlib only.
Thread-safe for MCP server use.
"""

from __future__ import annotations

import json
import logging
import re
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from threading import Lock

from .canon import (
    BOOKS_BY_OSIS,
    NAME_TO_OSIS,
    parse_reference as canon_parse_reference,
    is_old_testament,
)

# ---------------------------------------------------------------------------
# Regex patterns (compiled once at module level)
# ---------------------------------------------------------------------------

_STRONGS_TAG = re.compile(r"<S>(\d+)</S>")
_SUP_TAG = re.compile(r"<sup>[^<]*</sup>")
_HTML_TAG = re.compile(r"<[^>]+>")

# Books where KJV versification differs from Catholic editions due to
# deuterocanonical additions shifting verse/chapter numbering.
# For these, skip KJV Strong's and go directly to LXX Greek annotation.
# NOTE: checked by book name since Accordance binary modules use different
# book_id numbering than canon.py's bolls.life-based IDs.
_KJV_VERSIFICATION_CONFLICT_NAMES: set[str] = {
    "Daniel",  # KJV lacks Song of Azariah (Dan 3:24-90), Susanna, Bel
    "Esther",  # KJV lacks Greek additions shifting chapter structure
}

# ---------------------------------------------------------------------------
# Lazy fallback singletons (populated on first use inside get_verse_with_source)
# ---------------------------------------------------------------------------

_bolls_cache = None
_bolls_cache_failed = False
_sword_importer = None
_sword_import_failed = False

# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------


@dataclass
class VerseResult:
    """Result of a verse lookup.

    Can be used as a string (returns .text). Access .raw_text, .strongs,
    .reference, .translation for structured fields.
    """

    text: str  # Clean text, <S> tags stripped, HTML stripped
    raw_text: str  # Original text with <S> tags
    strongs: List[str]  # e.g. ['H3068', 'H0559', 'H0087']
    reference: str  # e.g. 'Genesis 12:1'
    translation: str  # e.g. 'KJV'
    book_id: int
    chapter: int
    verse: int

    def __str__(self) -> str:
        return self.text

    def __repr__(self) -> str:
        return (
            f"VerseResult({self.reference}, {self.translation}, {len(self.text)} chars)"
        )

    def __bool__(self) -> bool:
        return bool(self.text)

    def __len__(self) -> int:
        return len(self.text)


# ---------------------------------------------------------------------------
# Backward-compatible book name lookups (built from shared canon.py)
# Prefer canon.resolve_book_id() and canon.BOOKS_BY_OSIS for new code.
# ---------------------------------------------------------------------------

# bolls_id → OSIS reverse lookup (for testament checks)
_BOLLS_ID_TO_OSIS: dict[int, str] = {}
for osis_key, book_obj in BOOKS_BY_OSIS.items():
    if book_obj.bolls_id is not None:
        _BOLLS_ID_TO_OSIS[book_obj.bolls_id] = osis_key


def _build_backward_compat():
    """Build backward-compatible BOOK_NAME_TO_ID and BOOK_ID_TO_NAME dicts."""
    BOOK_NAME_TO_ID: dict[str, int] = {}
    # From canon NAME_TO_OSIS
    for name, osis_key in NAME_TO_OSIS.items():
        book = BOOKS_BY_OSIS.get(osis_key)
        if book and book.bolls_id is not None:
            BOOK_NAME_TO_ID[name] = book.bolls_id
    # Add spaced forms too (original behavior)
    for osis_key, book in BOOKS_BY_OSIS.items():
        if book.bolls_id is not None:
            BOOK_NAME_TO_ID[book.name.lower()] = book.bolls_id
    BOOK_ID_TO_NAME = {
        b.bolls_id: b.name for b in BOOKS_BY_OSIS.values() if b.bolls_id is not None
    }
    return BOOK_NAME_TO_ID, BOOK_ID_TO_NAME


BOOK_NAME_TO_ID, BOOK_ID_TO_NAME = _build_backward_compat()

# ---------------------------------------------------------------------------
# Translation code mapping (bolls.life codes)
# ---------------------------------------------------------------------------

BOLLS_CODES: Dict[str, str] = {
    "kjv": "KJV",
    "kjvs": "KJV",
    "esv": "ESV",
    "esvs": "ESV",
    "nasb": "NASB",
    "niv": "NIV",
    "nlt": "NLT",
    "nkjv": "NKJV",
    "web": "WEB",
    "nrsv": "NRSVCE",
    "nrsvce": "NRSVCE",
    "nabre": "NABRE",
    "nab": "NABRE",
    "drb": "DRB",
    "douay": "DRB",
    "douayrheims": "DRB",
    "douay-rheims": "DRB",
    "tr": "TR",  # Textus Receptus (Greek NT)
    "tisch": "TISCH",  # Tischendorf (Greek NT)
    "lxx": "LXX",  # Septuagint (Greek OT)
    "wlc": "WLC",  # Westminster Leningrad Codex (Hebrew OT)
    "opengnt": "OPENGNT",  # Open Greek NT (CC BY-SA 4.0, closest to NA28)
    "morphhb": "MORPHHB",  # OpenScriptures Hebrew Bible with Strong's (CC BY 4.0)
    "nets": "NETS",  # New English Translation of the Septuagint (LXX in English)
}

# Reverse lookup (bolls code -> common lowercase module name)
_BOLLS_REVERSE: Dict[str, str] = {v.lower(): k for k, v in BOLLS_CODES.items()}

# Testament scope mapping for translations that don't cover the full Bible.
# "ot" = Old Testament only (books 1-39), "nt" = New Testament only (books 40-66).
# Translations not in this dict are assumed to cover the full Bible.
TRANSLATION_TESTAMENT: Dict[str, str] = {
    "tr": "nt",  # NT only (books 40-66)
    "tisch": "nt",  # NT only
    "wlc": "ot",  # OT only (books 1-39)
    "lxx": "ot",  # OT only
    "opengnt": "nt",  # NT only
    "morphhb": "ot",  # OT only
    "nets": "ot",  # LXX (OT + Apocrypha, no NT)
}

# Canonical set of unique translation codes (deduplicated from BOLLS_CODES).
AVAILABLE_TRANSLATIONS: Dict[str, dict] = {
    "KJV": {"aliases": ["kjv", "kjvs"], "scope": "full"},
    "ESV": {"aliases": ["esv", "esvs"], "scope": "protestant_66"},
    "NASB": {"aliases": ["nasb"], "scope": "protestant_66"},
    "NIV": {"aliases": ["niv"], "scope": "protestant_66"},
    "NLT": {"aliases": ["nlt"], "scope": "protestant_66"},
    "NKJV": {"aliases": ["nkjv"], "scope": "protestant_66"},
    "WEB": {"aliases": ["web"], "scope": "protestant_66"},
    "NRSVCE": {"aliases": ["nrsv", "nrsvce"], "scope": "full"},
    "NABRE": {"aliases": ["nabre", "nab"], "scope": "full"},
    "DRB": {
        "aliases": ["drb", "douay", "douayrheims", "douay-rheims"],
        "scope": "partial",
    },
    "TR": {"aliases": ["tr"], "scope": "nt"},
    "TISCH": {"aliases": ["tisch"], "scope": "nt"},
    "LXX": {"aliases": ["lxx"], "scope": "ot"},
    "WLC": {"aliases": ["wlc"], "scope": "ot"},
    "OPENGNT": {"aliases": ["opengnt"], "scope": "nt"},
    "MORPHHB": {"aliases": ["morphhb"], "scope": "ot"},
    "NETS": {"aliases": ["nets"], "scope": "ot"},
}


def list_available_translations() -> dict[str, dict]:
    """Return all available Bible translations with metadata.

    Returns a dict mapping translation codes (e.g., ``"KJV"``) to metadata:
    ``aliases`` (list of accepted input codes) and ``scope`` (``"full"``,
    ``"ot"``, or ``"nt"``).

    Useful for LLM consumers that need to discover which translations are
    queryable before issuing ``get_verse`` or ``compare_translations`` calls.
    """
    return dict(AVAILABLE_TRANSLATIONS)


# ---------------------------------------------------------------------------
# Reference parsing
# ---------------------------------------------------------------------------


def parse_reference(
    reference: str,
) -> Optional[Tuple[int, int, int, Optional[int]]]:
    """Parse a Bible reference string. Returns (book_id, chapter, verse, end_verse).

    Delegates to the shared canon.parse_reference() for book-name resolution
    and returns bolls integer IDs for backward compatibility.
    """
    result = canon_parse_reference(reference, canon="protestant")
    if result is None:
        return None
    if result.verse is None:
        return None  # Chapter-only references not supported by old API
    book = BOOKS_BY_OSIS.get(result.book_osis)
    if book is None or book.bolls_id is None:
        return None
    return (book.bolls_id, result.chapter, result.verse, result.end_verse)


# ---------------------------------------------------------------------------
# Text cleaning
# ---------------------------------------------------------------------------


def clean_text(raw: str) -> str:
    """Strip <S>NNN</S>, <sup>...</sup>, and other HTML tags. Normalize whitespace.

    Args:
        raw: Raw verse text from bolls.life API or local JSON.

    Returns:
        Clean, human-readable text with no markup.
    """
    # 1. Remove <sup>...</sup> tags and their content (translator notes)
    text = _SUP_TAG.sub("", raw)
    # 2. Remove <S>NNN</S> tags (keep surrounding text)
    text = _STRONGS_TAG.sub("", text)
    # 3. Remove any remaining HTML/XML tags
    text = _HTML_TAG.sub("", text)
    # 4. Normalize whitespace
    text = " ".join(text.split())
    return text


def extract_strongs(raw: str, is_ot: bool = True) -> List[str]:
    """Extract Strong's numbers from raw verse text with H/G prefix.

    Args:
        raw: Raw verse text containing <S>NNNN</S> tags.
        is_ot: True for OT books (H prefix), False for NT books (G prefix).

    Returns:
        List of Strong's codes in order of appearance, e.g. ['H3068', 'H0559'].
        Numbers are zero-padded to 4 digits. Duplicates are preserved.
    """
    prefix = "H" if is_ot else "G"
    numbers = _STRONGS_TAG.findall(raw)
    return [f"{prefix}{int(n):04d}" for n in numbers]


def parse_with_strongs(
    raw_text: str, is_ot: bool = True
) -> List[Tuple[str, Optional[str]]]:
    """Parse raw verse text with <S>NNN</S> tags into aligned (word, strongs) pairs.

    Each <S> tag in the raw text follows the word it modifies.  This function
    splits on <S> tags and associates each Strong's number with the last word
    in the preceding text segment.  Untranslated Hebrew particles (like the
    direct-object marker <S>853</S>) that have no English word are attached
    to the preceding word.

    Args:
        raw_text: Raw verse text containing <S>NNN</S> tags.
        is_ot: True for OT books (H prefix), False for NT books (G prefix).

    Returns:
        List of (word, strongs_or_None) tuples in English word order.
        e.g. ``[("In", None), ("the", None), ("beginning", "H7225"), ...]``
    """
    prefix = "H" if is_ot else "G"
    parts = re.split(r"(<S>\d+</S>)", raw_text)

    result: List[Tuple[str, Optional[str]]] = []

    for part in parts:
        tag_match = re.match(r"<S>(\d+)</S>", part)
        if tag_match:
            num = int(tag_match.group(1))
            strongs_code = f"{prefix}{num:04d}"
            # Attach to the last word in result (if any)
            if result and result[-1][1] is None:
                result[-1] = (result[-1][0], strongs_code)
            elif result:
                # Last word already has a Strong's — stack it
                prev_word, prev_strongs = result[-1]
                result[-1] = (prev_word, f"{prev_strongs}, {strongs_code}")
            else:
                result.append(("\u2014", strongs_code))
        else:
            # Text segment — clean and tokenize
            clean = clean_text(part)
            for word in clean.split():
                result.append((word, None))

    return result


# ---------------------------------------------------------------------------
# Forward index: verse → Strong's numbers (from local KJV JSON, zero-network)
# ---------------------------------------------------------------------------

# {(book_id, chapter, verse): ["H3068", "H0559", ...]}
_VERSE_STRONGS: Dict[Tuple[int, int, int], List[str]] = {}
_VERSE_STRONGS_LOCK: Lock = Lock()


def _ensure_verse_strongs() -> None:
    """Build verse->Strong's forward index from local KJV JSON, once.

    Thread-safe. Populates _VERSE_STRONGS. Call before get_verse_strongs().
    Building takes <2s for all 37K+ verses on first call; no-op thereafter.
    """
    global _VERSE_STRONGS
    if _VERSE_STRONGS:
        return

    with _VERSE_STRONGS_LOCK:
        if _VERSE_STRONGS:
            return

        path = _find_kjv_json()
        if path is None:
            return

        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)

        strongs_tag = re.compile(r"<S>(\d+)</S>")
        index: Dict[Tuple[int, int, int], List[str]] = {}
        for verse in data:
            book = verse.get("book")
            chapter = verse.get("chapter")
            vnum = verse.get("verse")
            text = verse.get("text", "")
            if book is None or chapter is None or vnum is None:
                continue
            nums = strongs_tag.findall(text)
            if nums:
                prefix = (
                    "H" if is_old_testament(_BOLLS_ID_TO_OSIS.get(book, "")) else "G"
                )
                index[(book, chapter, vnum)] = [f"{prefix}{int(n):04d}" for n in nums]

        _VERSE_STRONGS = index


def get_verse_strongs(book_id: int, chapter: int, verse: int) -> Optional[List[str]]:
    """Get Strong's numbers for a verse from local KJV JSON. Zero network.

    Args:
        book_id: bolls book ID (1-66).
        chapter: Chapter number.
        verse: Verse number.

    Returns:
        List of Strong's codes like ['H3068', 'H0559'], or None if not found.
        Order is word order within the verse. Duplicates are preserved.
    """
    _ensure_verse_strongs()
    return _VERSE_STRONGS.get((book_id, chapter, verse))


# ---------------------------------------------------------------------------
# Local translation JSON caches (generalized from KJV-only)
# ---------------------------------------------------------------------------

# Per-translation cache: {translation_code: {(book, ch, v): raw_text}}
_CACHED_TRANSLATIONS: Dict[str, Dict[Tuple[int, int, int], str]] = {}
_CACHED_TRANSLATIONS_LOCK: Lock = Lock()

# Resolved JSON file paths, keyed by translation code
_TRANSLATION_JSON_PATHS: Dict[str, Path] = {}

# Data directory: project_root/data/
_DATA_DIR = Path(__file__).resolve().parent.parent / "data"

# KJV-specific paths (kept for backward compatibility)
_KJV_CACHE: Optional[Dict[Tuple[int, int, int], str]] = None
_KJV_CACHE_LOCK: Lock = Lock()
_KJV_DATA_DIR = _DATA_DIR
_KJV_JSON_PATH = _KJV_DATA_DIR / "kjv.json"

# Fallback path (original location from Citation App project)
_KJV_FALLBACK_PATH = Path(
    "/Users/jacobgruber/Projects/Citation App/backend/app/data/kjv.json"
)

# Home directory fallback
_KJV_HOME_FALLBACK = Path.home() / ".accordance" / "kjv.json"


def _get_kjv_strongs_fallback(
    book_id: int, chapter: int, verse: int, is_ot: bool
) -> List[str]:
    """Cross-reference KJV Strong's numbers when target translation lacks them.

    When a cached translation (NABRE, NRSVCE, etc.) doesn't have inline
    Strong's tags, look up the same verse in KJV (which bolls.life tags
    comprehensively) and return its Strong's numbers.
    For deuterocanonical books or books with versification conflicts
    (Daniel, Esther), fall back to LXX Greek annotation via LXXDeuteroAnnotator.
    """
    # Skip KJV for books with known versification conflicts
    book_name = BOOK_ID_TO_NAME.get(book_id)
    skip_kjv = book_name is not None and book_name in _KJV_VERSIFICATION_CONFLICT_NAMES

    if not skip_kjv:
        try:
            book_name = BOOK_ID_TO_NAME.get(book_id)
            if not book_name:
                return []
            kjv_vr = get_verse(f"{book_name} {chapter}:{verse}", "kjv")
            if kjv_vr is not None and kjv_vr.strongs:
                return kjv_vr.strongs
        except Exception:
            pass

    # For versification conflict books (Daniel, Esther), use LXX directly
    # since LXX versification aligns with Catholic editions (both Greek-based)
    if skip_kjv:
        try:
            from .lxx_deutero import LXXDeuteroAnnotator

            annotator = LXXDeuteroAnnotator()
            if annotator.available():
                sn_data = annotator.get_strongs_for_verse(book_name, chapter, verse)
                if sn_data and "message" not in sn_data[0]:
                    return [s.get("strongs", "") for s in sn_data if s.get("strongs")]
        except Exception:
            pass
        return []

    # LXX fallback for deuterocanonical books (book_id >= 67)
    if book_id >= 67:
        try:
            from .lxx_deutero import LXXDeuteroAnnotator

            annotator = LXXDeuteroAnnotator()
            if annotator.available():
                sn_data = annotator.get_strongs_for_verse(book_name, chapter, verse)
                if sn_data and "message" not in sn_data[0]:
                    return [s.get("strongs", "") for s in sn_data if s.get("strongs")]
        except Exception:
            pass

    return []


def _find_kjv_json() -> Optional[Path]:
    """Find the KJV JSON file across multiple possible locations."""
    candidates = [
        _KJV_JSON_PATH,
        _KJV_FALLBACK_PATH,
        _KJV_HOME_FALLBACK,
    ]
    for path in candidates:
        if path.exists():
            _TRANSLATION_JSON_PATHS["kjv"] = path
            return path
    return None


def _find_translation_json(translation: str) -> Optional[Path]:
    """Find the JSON file for a translation.

    For KJV, uses the multi-path search. For other translations, looks in
    data/{translation}.json.
    """
    trans_key = translation.lower()

    # Check previously resolved paths
    if trans_key in _TRANSLATION_JSON_PATHS:
        return _TRANSLATION_JSON_PATHS[trans_key]

    # KJV uses the existing multi-path search
    if trans_key in ("kjv", "kjvs"):
        path = _find_kjv_json()
        if path:
            _TRANSLATION_JSON_PATHS[trans_key] = path
        return path

    # Other translations: data/{translation}.json
    path = _DATA_DIR / f"{trans_key}.json"
    if path.exists():
        _TRANSLATION_JSON_PATHS[trans_key] = path
        return path

    return None


def _load_cached_translation(
    translation: str,
) -> Dict[Tuple[int, int, int], str]:
    """Load a cached translation JSON into {(book, ch, v): raw_text} dict.

    Cached after first load. Thread-safe with double-check locking.
    Works for any translation that has a JSON file in data/.
    """
    trans_key = translation.lower()

    # Fast path: already cached
    cached = _CACHED_TRANSLATIONS.get(trans_key)
    if cached is not None:
        return cached

    with _CACHED_TRANSLATIONS_LOCK:
        # Double-check inside lock
        cached = _CACHED_TRANSLATIONS.get(trans_key)
        if cached is not None:
            return cached

        path = _find_translation_json(trans_key)
        if path is None:
            _CACHED_TRANSLATIONS[trans_key] = {}
            return _CACHED_TRANSLATIONS[trans_key]

        cache: Dict[Tuple[int, int, int], str] = {}
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)

        for verse in data:
            book = verse.get("book")
            chapter = verse.get("chapter")
            vnum = verse.get("verse")
            text = verse.get("text", "")
            if book is not None and chapter is not None and vnum is not None:
                cache[(book, chapter, vnum)] = text

        _CACHED_TRANSLATIONS[trans_key] = cache
        return _CACHED_TRANSLATIONS[trans_key]


def lookup_cached_verse(
    translation: str,
    book_id: int,
    chapter: int,
    verse: int,
) -> Optional[VerseResult]:
    """Look up a verse in a locally cached translation JSON.

    Works for any cached translation (KJV, ESV, NASB, NABRE, etc.).

    Args:
        translation: Translation code ('kjv', 'esv', etc.).
        book_id: bolls book ID (1-66).
        chapter: Chapter number.
        verse: Verse number.

    Returns:
        VerseResult with text and Strong's numbers, or None if not found.
    """
    cache = _load_cached_translation(translation)
    key = (book_id, chapter, verse)
    raw_text = cache.get(key)
    if raw_text is None:
        return None

    book_name = BOOK_ID_TO_NAME.get(book_id, f"Book {book_id}")
    is_ot = is_old_testament(_BOLLS_ID_TO_OSIS.get(book_id, ""))
    bolls_code = _resolve_translation(translation)

    return VerseResult(
        text=clean_text(raw_text),
        raw_text=raw_text,
        strongs=extract_strongs(raw_text, is_ot=is_ot),
        reference=f"{book_name} {chapter}:{verse}",
        translation=bolls_code,
        book_id=book_id,
        chapter=chapter,
        verse=verse,
    )


def _load_kjv_local() -> Dict[Tuple[int, int, int], str]:
    """Load local KJV JSON into {(book, ch, v): raw_text} dict.

    Delegates to _load_cached_translation for the actual loading;
    maintained for backward compatibility with existing callers.
    """
    return _load_cached_translation("kjv")


def lookup_kjv_local(book_id: int, chapter: int, verse: int) -> Optional[VerseResult]:
    """Local KJV lookup. No network required.

    Delegates to lookup_cached_verse for the actual lookup.

    Args:
        book_id: bolls book ID (1-66).
        chapter: Chapter number.
        verse: Verse number.

    Returns:
        VerseResult with text and Strong's numbers, or None if not found.
    """
    return lookup_cached_verse("kjv", book_id, chapter, verse)


# ---------------------------------------------------------------------------
# bolls.life API fetcher (stdlib only - urllib)
# ---------------------------------------------------------------------------

_BOLLS_BASE = "https://bolls.life"
_REQUEST_TIMEOUT = 15  # seconds


def _resolve_translation(translation: str) -> str:
    """Resolve a translation code to the bolls.life API code.

    Accepts common lowercase abbreviations (e.g. 'kjv', 'esv', 'kjvs')
    and returns the bolls.life API code (e.g. 'KJV', 'ESV').
    """
    code = BOLLS_CODES.get(translation.lower(), translation)
    return code


def fetch_bolls_verse(
    translation: str, book_id: int, chapter: int, verse: int
) -> Optional[VerseResult]:
    """Fetch a single verse from the bolls.life API.

    Args:
        translation: Translation code (see BOLLS_CODES).
        book_id: bolls book ID (1-66).
        chapter: Chapter number.
        verse: Verse number.

    Returns:
        VerseResult or None on failure (network error, invalid response, etc.).
    """
    bolls_code = _resolve_translation(translation)
    url = f"{_BOLLS_BASE}/get-verse/{bolls_code}/{book_id}/{chapter}/{verse}/"

    try:
        req = urllib.request.Request(url, headers={"User-Agent": "AccordancePort/1.0"})
        with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp:
            if resp.status != 200:
                return None
            data = json.loads(resp.read().decode("utf-8"))
    except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError):
        # Network errors, HTTP errors, JSON decode errors
        return None

    raw_text = data.get("text", "")
    if not raw_text:
        return None

    book_name = BOOK_ID_TO_NAME.get(book_id, f"Book {book_id}")
    is_ot = is_old_testament(_BOLLS_ID_TO_OSIS.get(book_id, ""))
    vnum = data.get("verse", verse)

    return VerseResult(
        text=clean_text(raw_text),
        raw_text=raw_text,
        strongs=extract_strongs(raw_text, is_ot=is_ot),
        reference=f"{book_name} {chapter}:{vnum}",
        translation=bolls_code,
        book_id=book_id,
        chapter=chapter,
        verse=vnum,
    )


def fetch_bolls_chapter(
    translation: str, book_id: int, chapter: int
) -> List[VerseResult]:
    """Fetch an entire chapter from the bolls.life API.

    Args:
        translation: Translation code (see BOLLS_CODES).
        book_id: bolls book ID (1-66).
        chapter: Chapter number.

    Returns:
        List of VerseResult for each verse in the chapter.
        Empty list on failure.
    """
    bolls_code = _resolve_translation(translation)
    url = f"{_BOLLS_BASE}/get-text/{bolls_code}/{book_id}/{chapter}/"

    try:
        req = urllib.request.Request(url, headers={"User-Agent": "AccordancePort/1.0"})
        with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp:
            if resp.status != 200:
                return []
            data = json.loads(resp.read().decode("utf-8"))
    except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError):
        return []

    if not isinstance(data, list):
        return []

    book_name = BOOK_ID_TO_NAME.get(book_id, f"Book {book_id}")
    is_ot = is_old_testament(_BOLLS_ID_TO_OSIS.get(book_id, ""))
    results: List[VerseResult] = []

    for verse_data in data:
        raw_text = verse_data.get("text", "")
        if not raw_text:
            continue
        vnum = verse_data.get("verse", 0)
        results.append(
            VerseResult(
                text=clean_text(raw_text),
                raw_text=raw_text,
                strongs=extract_strongs(raw_text, is_ot=is_ot),
                reference=f"{book_name} {chapter}:{vnum}",
                translation=bolls_code,
                book_id=book_id,
                chapter=chapter,
                verse=vnum,
            )
        )

    return results


def _empty_search_result(page: int = 1, page_size: int = 20) -> dict:
    """Return an empty search result dict with the same shape as a real result."""
    return {"results": [], "total": 0, "page": page, "page_size": page_size}


# ---------------------------------------------------------------------------
# bolls.life v2/find search
# ---------------------------------------------------------------------------


def search_bolls(
    query: str,
    translation: str = "KJV",
    match_case: bool = False,
    match_whole: bool = False,
    book: str = "",
    page: int = 1,
    page_size: int = 20,
    timeout: int = 15,
) -> dict:
    """Search the bolls.life API with advanced options.

    Uses the v2/find endpoint which supports:
    - match_case: case-sensitive search
    - match_whole: whole-word matching
    - book: filter by testament (``"OT"`` or ``"NT"``) or specific book
    - page/page_size: pagination
    - Multilingual: Hebrew, Greek, Cyrillic, Chinese scripts supported

    Args:
        query: Search query string.
        translation: Translation code (e.g. ``"KJV"``, ``"ESV"``).
        match_case: Enable case-sensitive search.
        match_whole: Enable whole-word matching.
        book: Filter by testament (``"OT"``, ``"NT"``) or specific book.
        page: Page number (1-indexed).
        page_size: Results per page.
        timeout: Request timeout in seconds.

    Returns:
        ``{"results": [...], "total": int, "page": int, "page_size": int}``.
        Each result is a dict with ``text``, ``translation``, ``book``,
        ``chapter``, ``verse``. Returns empty results on any error.
    """
    bolls_code = _resolve_translation(translation)
    url = f"{_BOLLS_BASE}/v2/find"

    body = {
        "translation": bolls_code,
        "query": query,
        "page": page,
        "page_size": page_size,
    }
    if match_case:
        body["match_case"] = True
    if match_whole:
        body["match_whole"] = True
    if book:
        body["book"] = book

    try:
        data = json.dumps(body).encode("utf-8")
        req = urllib.request.Request(
            url,
            data=data,
            headers={
                "User-Agent": "AccordancePort/1.0",
                "Content-Type": "application/json",
            },
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            if resp.status != 200:
                return _empty_search_result(page, page_size)
            response = json.loads(resp.read().decode("utf-8"))
    except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError):
        return _empty_search_result(page, page_size)

    # Defensive parsing: handle both list and dict response shapes
    if isinstance(response, list):
        results_raw = response
        total = len(response)
    elif isinstance(response, dict):
        results_raw = response.get("results", [])
        total = response.get("total", len(results_raw))
    else:
        return _empty_search_result(page, page_size)

    results = []
    for item in results_raw:
        if isinstance(item, dict):
            raw_text = item.get("text", "")
            clean = _HTML_TAG.sub("", raw_text)
            clean = " ".join(clean.split())
            results.append(
                {
                    "text": clean,
                    "translation": bolls_code,
                    "book": item.get("book", ""),
                    "chapter": item.get("chapter", 0),
                    "verse": item.get("verse", 0),
                }
            )
        elif isinstance(item, str):
            clean = _HTML_TAG.sub("", item)
            clean = " ".join(clean.split())
            results.append(
                {
                    "text": clean,
                    "translation": bolls_code,
                    "book": "",
                    "chapter": 0,
                    "verse": 0,
                }
            )

    return {
        "results": results,
        "total": total,
        "page": page,
        "page_size": page_size,
    }


# ---------------------------------------------------------------------------
# Primary entry point
# ---------------------------------------------------------------------------


def get_verse(reference: str, translation: str = "kjv") -> Optional[VerseResult]:
    """Look up a verse by reference string and translation.

    Primary entry point. Tries multiple layers in priority order:
    1. Local project JSON cache (zero network)
    2. bolls_cache (~/.accordance-port/cache/bolls/)
    3. bolls.life live API
    4. SwordImporter (CrossWire/SWORD modules via pysword)

    Args:
        reference: String like 'Genesis 12:1', 'Gen 12:1', 'John 3:16', etc.
        translation: Translation code ('kjv', 'esv', etc.). See BOLLS_CODES.

    Returns:
        VerseResult or None if not found.
    """
    result, _source = get_verse_with_source(reference, translation)
    # Universal Strong's enrichment: if any translation lacks Strong's,
    # cross-reference KJV (which bolls.life tags comprehensively)
    if result is not None and not result.strongs and translation.lower() != "kjv":
        result.strongs = _get_kjv_strongs_fallback(
            result.book_id,
            result.chapter,
            result.verse,
            is_ot=is_old_testament(result.book_id),
        )
    return result


def get_verse_with_source(
    reference: str, translation: str = "kjv"
) -> Tuple[Optional[VerseResult], Optional[str]]:
    """Look up a verse and return (result, data_source).

    Tries multiple layers in priority order:
    1. Local project JSON cache (data/{translation}.json) — zero network
    2. bolls_cache (~/.accordance-port/cache/bolls/) — shared static cache
    3. bolls.life live API — network call
    4. SwordImporter (CrossWire/SWORD modules via pysword) — last resort

    Each layer is wrapped in try/except so no single failure kills the chain.

    Returns:
        (VerseResult, source_name) tuple.  source_name is one of:
        ``"local_cache"``, ``"bolls_cache"``, ``"bolls_api"``,
        ``"sword"``, or ``None`` if not found.
    """
    global _bolls_cache, _bolls_cache_failed
    global _sword_importer, _sword_import_failed

    parsed = parse_reference(reference)
    if parsed is None:
        return None, None

    book_id, chapter, verse, end_verse = parsed
    bolls_code = _resolve_translation(translation)

    # ---- Layer 1: Local project JSON cache (zero network) ----
    result = lookup_cached_verse(translation, book_id, chapter, verse)
    if result is not None:
        return result, "local_cache"

    book_name = BOOK_ID_TO_NAME.get(book_id, f"Book {book_id}")
    is_ot = is_old_testament(_BOLLS_ID_TO_OSIS.get(book_id, ""))

    # ---- Layer 1b: Accordance binary TextModule (for NETS, etc.) ----
    try:
        from .discovery import discover_modules

        mods = discover_modules()
        text_mods = {a: i for a, i in mods.items() if i["type"] == 1}
        if bolls_code in text_mods or translation.upper() in text_mods:
            from .text_module import TextModule

            mod_abbr = bolls_code if bolls_code in text_mods else translation.upper()
            tm = TextModule(text_mods[mod_abbr]["path"], mod_abbr)
            ref_str = f"{book_name} {chapter}:{verse}"
            raw = tm.get_verse(ref_str)
            if raw:
                # Check for inline Strong's tags
                has_tags = "<S>" in raw or "<s>" in raw.lower()
                return (
                    VerseResult(
                        text=raw,
                        raw_text=raw,
                        strongs=extract_strongs(raw, is_ot=is_ot) if has_tags else [],
                        reference=ref_str,
                        translation=bolls_code,
                        book_id=book_id,
                        chapter=chapter,
                        verse=verse,
                    ),
                    "text_module",
                )
    except Exception:
        pass

    # ---- Layer 2: bolls_cache (shared bolls.life static JSON) ----
    if not _bolls_cache_failed:
        try:
            if _bolls_cache is None:
                from .bolls_cache import BollsCache  # lazy import

                _bolls_cache = BollsCache()
            raw = _bolls_cache.get_cached_verse(bolls_code, book_id, chapter, verse)
            if raw:
                logging.warning(
                    "get_verse: served from bolls_cache for %s (%s)",
                    reference,
                    bolls_code,
                )
                strongs = extract_strongs(raw, is_ot=is_ot)
                return (
                    VerseResult(
                        text=clean_text(raw),
                        raw_text=raw,
                        strongs=strongs,
                        reference=f"{book_name} {chapter}:{verse}",
                        translation=bolls_code,
                        book_id=book_id,
                        chapter=chapter,
                        verse=verse,
                    ),
                    "bolls_cache",
                )
        except ImportError:
            _bolls_cache_failed = True
        except Exception:
            logging.warning(
                "get_verse: bolls_cache lookup failed for %s, falling through",
                reference,
            )

    # ---- Layer 3: bolls.life live API ----
    result = fetch_bolls_verse(translation, book_id, chapter, verse)
    if result is not None:
        return result, "bolls_api"

    # ---- Layer 4: SwordImporter (CrossWire/SWORD modules) ----
    if not _sword_import_failed:
        try:
            if _sword_importer is None:
                from .sword_importer import SwordImporter  # lazy import

                _sword_importer = SwordImporter()
            raw = _sword_importer.get_verse(bolls_code, book_name, chapter, verse)
            if raw:
                logging.warning(
                    "get_verse: fell back to SwordImporter for %s (%s)",
                    reference,
                    bolls_code,
                )
                return (
                    VerseResult(
                        text=clean_text(raw),
                        raw_text=raw,
                        strongs=extract_strongs(raw, is_ot=is_ot),
                        reference=f"{book_name} {chapter}:{verse}",
                        translation=bolls_code,
                        book_id=book_id,
                        chapter=chapter,
                        verse=verse,
                    ),
                    "sword",
                )
        except ImportError:
            _sword_import_failed = True
        except Exception:
            logging.warning(
                "get_verse: SwordImporter fallback failed for %s",
                reference,
            )

    return None, None


def get_verse_range(reference: str, translation: str = "kjv") -> List[VerseResult]:
    """Look up a verse reference, handling ranges like 'Acts 20:17-27'.

    For single-verse references, delegates to get_verse() (local cache + API).
    For ranges, fetches the entire chapter via API and filters to the range.

    Args:
        reference: String like 'John 3:16' or 'Acts 20:17-27'.
        translation: Translation code ('kjv', 'esv', 'nabre', etc.).

    Returns:
        List of VerseResult (empty list if not found).
    """
    parsed = parse_reference(reference)
    if parsed is None:
        return []

    book_id, chapter, verse, end_verse = parsed

    if end_verse is not None and end_verse > verse:
        # Range: fetch entire chapter in one API call and filter
        all_verses = fetch_bolls_chapter(translation, book_id, chapter)
        return [v for v in all_verses if verse <= v.verse <= end_verse]
    else:
        result = get_verse(reference, translation)
        return [result] if result else []


# ---------------------------------------------------------------------------
# Data setup utility
# ---------------------------------------------------------------------------


def setup_kjv_data() -> bool:
    """Copy KJV JSON from Citation App project into the local data directory.

    Returns:
        True if the file was copied or already exists at destination.
        False if the source was not found and destination doesn't exist.
    """
    import shutil

    dst = _KJV_JSON_PATH
    if dst.exists():
        return True

    src = _KJV_FALLBACK_PATH
    if not src.exists():
        return False

    dst.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(src, dst)
    return dst.exists()
