"""PassageContext — unified orchestrator for studying a Bible passage.

Ties together all 15+ source modules behind a single lazy-resolving
interface. Call .gather() to resolve everything concurrently, or
.to_markdown() for LLM-ready output.

Design: facade pattern — orchestrates existing modules without modifying them.
Lazy by default; concurrent resolution via ThreadPoolExecutor.
"""

from __future__ import annotations

import json
import logging
import pickle
import time
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from threading import Lock
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Cache paths
# ---------------------------------------------------------------------------

_CACHE_DIR = Path.home() / ".accordance-port" / "cache" / "passage_context"
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
_CACHE_TTL_SECONDS = 24 * 60 * 60  # 24 hours for API-sourced data


def _cache_key(reference: str) -> str:
    """Filesystem-safe cache key from a reference string."""
    safe = reference.replace(" ", "_").replace(":", "-").replace("/", "-")
    return f"{safe}.pickle"


def _cache_path(reference: str) -> Path:
    return _CACHE_DIR / _cache_key(reference)


# ---------------------------------------------------------------------------
# Core dataclass
# ---------------------------------------------------------------------------


@dataclass
class PassageContext:
    """Lazy-resolving context for a single Bible passage.

    Each section (translations, strongs, morphology, etc.) resolves on
    first access via a property getter.  Call ``.gather()`` for eager
    concurrent resolution, or ``.to_markdown()`` for LLM-ready output.

    Usage::

        ctx = PassageContext("John 3:16")
        ctx.gather()
        print(ctx.to_markdown())
    """

    reference: str
    _translations: dict = field(default_factory=dict, repr=False)
    _strongs: list = field(default_factory=list, repr=False)
    _morphology: list = field(default_factory=list, repr=False)
    _commentaries: list = field(default_factory=list, repr=False)
    _cross_references: list = field(default_factory=list, repr=False)
    _rabbinic: list = field(default_factory=list, repr=False)
    _classical: list = field(default_factory=list, repr=False)
    _academic: list = field(default_factory=list, repr=False)
    _resolved: set = field(default_factory=set, repr=False)
    warnings: List[str] = field(default_factory=list)
    missing_sections: List[str] = field(default_factory=list)
    _lock: Lock = field(default_factory=Lock, repr=False)

    # ------------------------------------------------------------------
    # Lazy property accessors
    # ------------------------------------------------------------------

    @property
    def translations(self) -> Dict[str, Any]:
        """dict[str, VerseResult] — KJV + available translations with source tracking."""
        self._resolve_section("translations", self._resolve_translations)
        return self._translations

    @property
    def strongs(self) -> List[dict]:
        """list[dict] — Strong's numbers with {word, strongs_num, definition, source}."""
        self._resolve_section("strongs", self._resolve_strongs)
        return self._strongs

    @property
    def morphology(self) -> List[dict]:
        """list[dict] — Morphology entries enriched with MACULA roles/referents."""
        self._resolve_section("morphology", self._resolve_morphology)
        return self._morphology

    @property
    def commentaries(self) -> List[dict]:
        """list[dict] — Commentary excerpts for this verse."""
        self._resolve_section("commentaries", self._resolve_commentaries)
        return self._commentaries

    @property
    def cross_references(self) -> List[dict]:
        """list[dict] — Cross-references from Accordance + Sefaria links."""
        self._resolve_section("cross_references", self._resolve_cross_references)
        return self._cross_references

    @property
    def rabbinic(self) -> List[dict]:
        """list[dict] — Rabbinic parallels from Sefaria."""
        self._resolve_section("rabbinic", self._resolve_rabbinic)
        return self._rabbinic

    @property
    def classical(self) -> List[dict]:
        """list[dict] — Classical/patristic parallels from Scaife."""
        self._resolve_section("classical", self._resolve_classical)
        return self._classical

    @property
    def academic(self) -> List[dict]:
        """list[dict] — Academic articles from DOAJ."""
        self._resolve_section("academic", self._resolve_academic)
        return self._academic

    # ------------------------------------------------------------------
    # Thread-safe lazy resolution helper
    # ------------------------------------------------------------------

    def _resolve_section(self, name: str, resolver) -> None:
        """Check-and-set pattern: resolve section at most once.

        Holds the lock for the entire check-resolve-mark sequence to
        prevent TOCTOU races when multiple threads access the same
        lazy property concurrently.  The I/O happens under the lock,
        but this only affects the lazy (non-gather) code path.
        """
        with self._lock:
            if name in self._resolved:
                return
            try:
                resolver()
            except Exception as e:
                self.warnings.append(f"{name}: {e}")
            finally:
                self._resolved.add(name)

    # ------------------------------------------------------------------
    # Individual resolvers (each is a private method, lazy imports)
    # ------------------------------------------------------------------

    def _resolve_translations(self) -> None:
        """Resolve NABRE + KJV + ESV + NRSVCE verse texts.

        NABRE is the primary (Catholic) translation; others provide
        broader coverage for comparative study.
        """
        translations_to_fetch = ["nabre", "kjv", "esv", "nrsvce"]
        resolved: Dict[str, Any] = {}
        for code in translations_to_fetch:
            try:
                from .bible_api import get_verse_with_source

                result, source = get_verse_with_source(self.reference, code)
                if result is not None:
                    resolved[result.translation] = {
                        "text": result.text,
                        "reference": result.reference,
                        "translation": result.translation,
                        "strongs": result.strongs,
                        "source": source,
                    }
                    resolved.setdefault("_sources", {})[result.translation] = source
            except ImportError:
                self.warnings.append(f"bible_api not available for {code}.")
            except Exception as e:
                self.warnings.append(f"Translation {code}: {e}")
        if not resolved or (
            len(resolved) == 0 or (len(resolved) == 1 and "_sources" in resolved)
        ):
            self.missing_sections.append("translations")
        self._translations = resolved

    def _resolve_strongs(self) -> None:
        """Get Strong's numbers from KJV text, enrich each."""
        # First ensure we have translations (especially KJV)
        if "translations" not in self._resolved:
            with self._lock:
                if "translations" not in self._resolved:
                    self._resolve_translations()
                    self._resolved.add("translations")

        kjv_entry = self._translations.get("KJV", {})
        kjv_strongs = kjv_entry.get("strongs", [])
        if not kjv_strongs:
            # Try any translation with strongs
            for trans_key, trans_data in self._translations.items():
                if trans_key.startswith("_"):
                    continue
                s = trans_data.get("strongs", [])
                if s:
                    kjv_strongs = s
                    break

        if not kjv_strongs:
            self.missing_sections.append("strongs (no Strong's numbers in verse)")
            self._strongs = []
            return

        results: List[dict] = []
        try:
            from .strongs_enrichment import StrongsEnricher

            enricher = StrongsEnricher()
            for sn in kjv_strongs:
                # Detect language: H-prefix = Hebrew, G-prefix = Greek
                lang = "hebrew" if sn.upper().startswith("H") else "greek"
                enriched = enricher.enrich_definition(sn, lang)
                results.append(
                    {
                        "word": "",  # word mapping requires MACULA; leave for morphology
                        "strongs_num": enriched.get("strongs_number", sn),
                        "definition": enriched.get("definition", ""),
                        "lemma": enriched.get("lemma", ""),
                        "source": enriched.get("source", "unknown"),
                    }
                )
        except ImportError:
            self.warnings.append("StrongsEnricher module not available.")
            # Fallback: just list the numbers
            for sn in kjv_strongs:
                results.append(
                    {
                        "word": "",
                        "strongs_num": sn,
                        "definition": "(Strong's enrichment unavailable)",
                        "lemma": "",
                        "source": "raw",
                    }
                )
        except Exception as e:
            self.warnings.append(f"Strong's enrichment: {e}")

        self._strongs = results

    def _resolve_morphology(self) -> None:
        """Get morphology with MACULA enrichment."""
        try:
            from .morphology import enrich_morphology

            # Try Greek first (NT), auto-detect by book
            result = enrich_morphology(self.reference, language="greek")
            words = result.get("words", [])
            if not words:
                # Try Hebrew (OT books)
                result = enrich_morphology(self.reference, language="hebrew")
                words = result.get("words", [])

            self._morphology = words
            if not words:
                self.missing_sections.append("morphology")
        except ImportError:
            self.warnings.append("Morphology module not available.")
            self.missing_sections.append("morphology")
            self._morphology = []
        except Exception as e:
            self.warnings.append(f"Morphology: {e}")
            self.missing_sections.append("morphology")
            self._morphology = []

    def _resolve_commentaries(self) -> None:
        """Search local FTS5 index + bolls for commentary excerpts on this verse."""
        results: List[dict] = []
        try:
            from .unified_search import unified

            hits = unified.search_by_category(
                self.reference, category="commentary", max_results=8
            )
            for h in hits:
                results.append(
                    {
                        "title": h.get("title", ""),
                        "snippet": h.get("snippet", ""),
                        "reference": h.get("reference", self.reference),
                        "source": h.get("source", ""),
                        "citation": _format_commentary_sbl(h),
                    }
                )
        except ImportError:
            self.warnings.append("unified_search module not available.")
        except Exception as e:
            self.warnings.append(f"Commentaries: {e}")

        if not results:
            self.missing_sections.append("commentaries")
        self._commentaries = results

    def _resolve_cross_references(self) -> None:
        """Get cross-references: Sefaria links + local FTS5 bible hits + openbible.info."""
        results: List[dict] = []

        # 1. Sefaria links (commentary connections for this verse)
        try:
            from .sefaria_api import sefaria

            # Convert "John 3:16" to Sefaria ref format "John.3.16"
            sefaria_ref = _to_sefaria_ref(self.reference)
            if sefaria_ref:
                links = sefaria.get_links(sefaria_ref)
                for link in links[:50]:
                    results.append(
                        {
                            "type": "sefaria_link",
                            "source_ref": link.get("ref", ""),
                            "category": link.get("category", ""),
                            "text": link.get("text", link.get("anchorText", "")),
                            "url": f"https://www.sefaria.org/{link.get('ref', '')}",
                        }
                    )
        except ImportError:
            pass  # Sefaria not available, skip silently
        except Exception as e:
            self.warnings.append(f"Sefaria links: {e}")

        # 2. Local FTS5 bible search for related verses
        try:
            from .unified_search import unified

            # Extract a search query from the reference (book name works as a topical seed)
            bible_hits = unified.search_by_category(
                self.reference, category="bible", max_results=5
            )
            for h in bible_hits:
                ref = h.get("reference", "")
                if ref != self.reference:  # Don't include the verse itself
                    results.append(
                        {
                            "type": "bible_cross_ref",
                            "reference": ref,
                            "snippet": h.get("snippet", ""),
                            "source": h.get("source", ""),
                        }
                    )
        except ImportError:
            pass
        except Exception as e:
            self.warnings.append(f"Cross-references (local): {e}")

        # 3. openbible.info cross-references (thematic/narrative parallels)
        try:
            from .openbible_xrefs import is_available, lookup

            if is_available():
                ob_refs = lookup(self.reference)
                for ref_str in ob_refs[:50]:  # limit to avoid bloat
                    parts = ref_str.split(" ", 1)
                    osis = parts[0]
                    ch_vs = parts[1] if len(parts) > 1 else ""
                    results.append(
                        {
                            "type": "openbible_xref",
                            "reference": ref_str,
                            "book_osis": osis,
                            "chapter_verse": ch_vs,
                        }
                    )
        except ImportError:
            pass
        except Exception as e:
            self.warnings.append(f"Cross-references (openbible): {e}")

        if not results:
            self.missing_sections.append("cross_references")
        self._cross_references = results

    def _resolve_rabbinic(self) -> None:
        """Search Sefaria for rabbinic texts referencing this verse."""
        results: List[dict] = []
        try:
            from .sefaria_api import sefaria

            hits = sefaria.search(self.reference, max_results=5)
            for h in hits:
                src = h.get("_source", {})
                title = (
                    src.get("titleVariants", [{}])[0].get("text", "")
                    or src.get("book", "")
                    or src.get("title", "")
                )
                results.append(
                    {
                        "title": title or "(untitled)",
                        "snippet": src.get("content", "") or src.get("text", ""),
                        "reference": src.get("ref", ""),
                        "url": f"https://www.sefaria.org/{src.get('ref', '')}",
                    }
                )
        except ImportError:
            self.warnings.append("Sefaria module not available.")
        except Exception as e:
            self.warnings.append(f"Sefaria (rabbinic): {e}")

        if not results:
            self.missing_sections.append("rabbinic")
        self._rabbinic = results

    def _resolve_classical(self) -> None:
        """Search Scaife for classical/patristic texts thematically related."""
        results: List[dict] = []
        try:
            from .scaife_api import scaife

            # Build a search query from the book name (e.g., "John" → patristic era)
            book_name = _extract_book_name(self.reference)
            works = scaife.search_by_author(book_name) if book_name else []
            for w in works[:5]:
                results.append(
                    {
                        "author": w.get("group", ""),
                        "work": w.get("work", ""),
                        "work_urn": w.get("work_urn", ""),
                        "language": w.get("language", ""),
                        "editions": w.get("editions", []),
                    }
                )
        except ImportError:
            self.warnings.append("Scaife module not available.")
        except Exception as e:
            self.warnings.append(f"Scaife (classical): {e}")

        if not results:
            self.missing_sections.append("classical")
        self._classical = results

    def _resolve_academic(self) -> None:
        """Search DOAJ for open-access theology articles."""
        results: List[dict] = []
        try:
            from .doaj_api import doaj

            # Use the book name + "theology" as a search seed
            book_name = _extract_book_name(self.reference) or self.reference
            query = f"{book_name}"
            articles = doaj.search_articles(query, max_results=5)
            for a in articles:
                bibjson = a.get("bibjson", {})
                title = bibjson.get("title", "")
                abstract = bibjson.get("abstract", "")
                # Authors
                authors = bibjson.get("author", [])
                if isinstance(authors, list) and authors:
                    author_str = ", ".join(
                        a_.get("name", "") for a_ in authors if isinstance(a_, dict)
                    )
                else:
                    author_str = ""
                # Journal
                journal = bibjson.get("journal", {})
                journal_title = (
                    journal.get("title", "") if isinstance(journal, dict) else ""
                )
                # Full-text link
                links = bibjson.get("link", [])
                link_url = ""
                if isinstance(links, list):
                    for lk in links:
                        if isinstance(lk, dict) and lk.get("type") in (
                            "fulltext",
                            "full-text",
                        ):
                            link_url = lk.get("url", "")
                            break
                    if not link_url and links:
                        link_url = (
                            links[0].get("url", "")
                            if isinstance(links[0], dict)
                            else ""
                        )

                results.append(
                    {
                        "title": title or "(untitled)",
                        "authors": author_str,
                        "journal": journal_title,
                        "abstract": abstract or "",
                        "url": link_url
                        or f"https://doaj.org/article/{a.get('id', '')}",
                    }
                )
        except ImportError:
            self.warnings.append("DOAJ module not available.")
        except Exception as e:
            self.warnings.append(f"DOAJ (academic): {e}")

        if not results:
            self.missing_sections.append("academic")
        self._academic = results

    # ------------------------------------------------------------------
    # gather() — eager concurrent resolution
    # ------------------------------------------------------------------

    def gather(
        self,
        *,
        include_rabbinic: bool = True,
        include_classical: bool = True,
        include_academic: bool = True,
        max_workers: int = 4,
        timeout: int = 15,
    ) -> None:
        """Resolve all sections concurrently via ThreadPoolExecutor.

        Each resolver runs in its own thread with its own try/except —
        failure in one section never blocks others.  Exceptions are
        captured as warnings.

        Args:
            include_rabbinic: Whether to query Sefaria for rabbinic texts.
            include_classical: Whether to query Scaife for classical texts.
            include_academic: Whether to query DOAJ for academic articles.
            max_workers: Max threads in the pool.
            timeout: Max seconds to wait for all futures.
        """
        resolvers: List[tuple] = [
            ("translations", self._resolve_translations),
            ("strongs", self._resolve_strongs),
            ("morphology", self._resolve_morphology),
            ("commentaries", self._resolve_commentaries),
            ("cross_references", self._resolve_cross_references),
        ]
        if include_rabbinic:
            resolvers.append(("rabbinic", self._resolve_rabbinic))
        if include_classical:
            resolvers.append(("classical", self._resolve_classical))
        if include_academic:
            resolvers.append(("academic", self._resolve_academic))

        # Check what's already resolved
        with self._lock:
            pending = [
                (name, fn) for name, fn in resolvers if name not in self._resolved
            ]

        if not pending:
            return

        # Submit all pending resolvers
        with ThreadPoolExecutor(max_workers=min(max_workers, len(pending))) as executor:
            future_map: Dict[Future, str] = {}
            for name, resolver_fn in pending:
                future = executor.submit(_safe_resolve, name, resolver_fn, self)
                future_map[future] = name

            try:
                for future in as_completed(future_map, timeout=timeout):
                    name = future_map[future]
                    try:
                        future.result(timeout=1)
                    except Exception:
                        pass  # already captured as warning in _safe_resolve
            except TimeoutError:
                self.warnings.append(
                    f"gather timed out after {timeout}s — some sources may be "
                    f"incomplete"
                )
                # Mark timed-out futures as missing
                for future, name in future_map.items():
                    if not future.done():
                        self.missing_sections.append(name)

    # ------------------------------------------------------------------
    # Caching
    # ------------------------------------------------------------------

    def cache_to_disk(self) -> bool:
        """Save current state to disk cache (~/.accordance-port/cache/passage_context/).

        Returns True on success, False on failure.
        """
        try:
            data = self.to_dict()
            data["_cached_at"] = time.time()
            path = _cache_path(self.reference)
            with open(path, "wb") as f:
                pickle.dump(data, f, protocol=pickle.HIGHEST_PROTOCOL)
            return True
        except Exception as e:
            logger.debug("Failed to cache PassageContext to disk: %s", e)
            return False

    @staticmethod
    def from_cache(reference: str) -> Optional[PassageContext]:
        """Try to load a cached PassageContext from disk.

        Returns None if cache is missing, expired, or corrupt.
        """
        path = _cache_path(reference)
        if not path.exists():
            return None
        try:
            with open(path, "rb") as f:
                data = pickle.load(f)
            cached_at = data.pop("_cached_at", 0)
            if time.time() - cached_at > _CACHE_TTL_SECONDS:
                # Expired; remove and return None
                path.unlink(missing_ok=True)
                return None
            ctx = PassageContext(reference)
            ctx._translations = data.get("translations", {})
            ctx._strongs = data.get("strongs", [])
            ctx._morphology = data.get("morphology", [])
            ctx._commentaries = data.get("commentaries", [])
            ctx._cross_references = data.get("cross_references", [])
            ctx._rabbinic = data.get("rabbinic", [])
            ctx._classical = data.get("classical", [])
            ctx._academic = data.get("academic", [])
            ctx.warnings = data.get("warnings", [])
            ctx.missing_sections = data.get("missing_sections", [])
            # Mark all present sections as resolved
            sections = [
                "translations",
                "strongs",
                "morphology",
                "commentaries",
                "cross_references",
                "rabbinic",
                "classical",
                "academic",
            ]
            resolved = set()
            resolved_mapping = {
                "translations": ctx._translations,
                "strongs": ctx._strongs,
                "morphology": ctx._morphology,
                "commentaries": ctx._commentaries,
                "cross_references": ctx._cross_references,
                "rabbinic": ctx._rabbinic,
                "classical": ctx._classical,
                "academic": ctx._academic,
            }
            for name in sections:
                if resolved_mapping[name]:  # Non-empty list/dict
                    resolved.add(name)
            ctx._resolved = resolved
            return ctx
        except Exception as e:
            logger.debug("Failed to load PassageContext from disk cache: %s", e)
            path.unlink(missing_ok=True)
            return None

    # ------------------------------------------------------------------
    # Serialization
    # ------------------------------------------------------------------

    def to_dict(self) -> Dict[str, Any]:
        """Return full nested dict representation."""
        return {
            "reference": self.reference,
            "translations": self._translations,
            "strongs": self._strongs,
            "morphology": self._morphology,
            "commentaries": self._commentaries,
            "cross_references": self._cross_references,
            "rabbinic": self._rabbinic,
            "classical": self._classical,
            "academic": self._academic,
            "warnings": self.warnings,
            "missing_sections": self.missing_sections,
        }

    def to_json(self, indent: int = 2) -> str:
        """Return JSON representation via to_dict()."""
        return json.dumps(
            self.to_dict(), indent=indent, ensure_ascii=False, default=str
        )

    def to_markdown(self) -> str:
        """Return structured markdown suitable for LLM consumption.

        Sections: ## Translations, ## Strong's, ## Morphology, ## Commentaries,
        ## Cross-References, ## Rabbinic Connections, ## Classical Parallels,
        ## Academic Literature.  Warnings and missing sections at top.
        """
        lines: List[str] = []

        # Header
        lines.append(f"# Passage Study: {self.reference}")
        lines.append("")

        # Warnings (before content)
        if self.warnings:
            lines.append("---")
            lines.append("**Warnings:**")
            for w in self.warnings:
                lines.append(f"- ⚠ {w}")
            lines.append("---")
            lines.append("")

        # Missing sections
        if self.missing_sections:
            lines.append(
                "*Some sections unavailable:* " + ", ".join(self.missing_sections)
            )
            lines.append("")

        # --- Translations ---
        lines.append("## Translations")
        lines.append("")
        if self._translations:
            for trans_name, trans_data in sorted(self._translations.items()):
                if trans_name.startswith("_"):
                    continue
                text = trans_data.get("text", "")
                source = trans_data.get("source", "")
                sref = trans_data.get("reference", "")
                src_note = f" (source: {source})" if source else ""
                lines.append(f"### {trans_name}{src_note}")
                lines.append(f"> {sref}")
                lines.append("")
                lines.append(f"> {text}")
                lines.append("")
        else:
            lines.append("*No translations resolved.*")
            lines.append("")

        # --- Strong's ---
        lines.append("## Strong's Numbers")
        lines.append("")
        if self._strongs:
            lines.append("| # | Strong's | Definition | Source |")
            lines.append("|---|----------|------------|--------|")
            for i, s in enumerate(self._strongs, 1):
                sn = s.get("strongs_num", "")
                definition = (s.get("definition", "") or "")[:120].replace("\n", " ")
                src = s.get("source", "")
                lines.append(f"| {i} | {sn} | {definition} | {src} |")
            lines.append("")
        else:
            lines.append("*No Strong's numbers available.*")
            lines.append("")

        # --- Morphology ---
        lines.append("## Morphology")
        lines.append("")
        if self._morphology:
            # Determine if any words have macula enrichment
            has_roles = any(w.get("semantic_role") for w in self._morphology)
            lines.append(
                "| Pos | Word | Strong's | Lemma | Morphology"
                + (" | Semantic Role" if has_roles else "")
                + " |"
            )
            lines.append(
                "|-----|------|----------|-------|-----------"
                + ("|--------------" if has_roles else "")
                + " |"
            )
            for w in self._morphology:
                pos = str(w.get("word_position", ""))
                word = w.get("word", "")
                strongs = w.get("strongs", "")
                lemma = w.get("lemma", "")
                morph = w.get("morph_description", "")
                role = w.get("semantic_role", "") or ""
                row = f"| {pos} | {word} | {strongs} | {lemma} | {morph}"
                if has_roles:
                    row += f" | {role}"
                row += " |"
                lines.append(row)
            lines.append("")
        else:
            lines.append("*No morphology data available.*")
            lines.append("")

        # --- Commentaries ---
        lines.append("## Commentaries")
        lines.append("")
        if self._commentaries:
            for i, c in enumerate(self._commentaries, 1):
                citation = c.get("citation", c.get("title", ""))
                snippet = c.get("snippet", "")
                lines.append(f"### {i}. {citation}")
                lines.append(f"> {snippet}")
                lines.append("")
        else:
            lines.append("*No commentary excerpts available.*")
            lines.append("")

        # --- Cross-References ---
        lines.append("## Cross-References")
        lines.append("")
        if self._cross_references:
            for cr in self._cross_references:
                ref_type = cr.get("type", "")
                if ref_type == "sefaria_link":
                    lines.append(
                        f"- **Sefaria:** [{cr.get('source_ref', '')}]({cr.get('url', '')}) "
                        f"— {cr.get('text', '')}"
                    )
                elif ref_type == "openbible_xref":
                    lines.append(f"- {cr.get('reference', '')} *(openbible.info)*")
                else:
                    lines.append(
                        f"- {cr.get('reference', '')}: {cr.get('snippet', '')}"
                    )
            lines.append("")
        else:
            lines.append("*No cross-references found.*")
            lines.append("")

        # --- Rabbinic Connections ---
        lines.append("## Rabbinic Connections")
        lines.append("")
        if self._rabbinic:
            for i, r in enumerate(self._rabbinic, 1):
                title = r.get("title", "")
                snippet = r.get("snippet", "")
                url = r.get("url", "")
                lines.append(f"### {i}. {title}")
                lines.append(f"> {snippet}")
                if url:
                    lines.append(f"[Read on Sefaria]({url})")
                lines.append("")
        else:
            lines.append("*No rabbinic connections found (or Sefaria unavailable).*")
            lines.append("")

        # --- Classical Parallels ---
        lines.append("## Classical Parallels")
        lines.append("")
        if self._classical:
            for c in self._classical:
                author = c.get("author", "")
                work = c.get("work", "")
                urn = c.get("work_urn", "")
                language = c.get("language", "")
                lang_label = (
                    "Greek"
                    if language in ("grc", "greek")
                    else "Latin"
                    if language in ("lat", "latin")
                    else language
                )
                lines.append(f"- **{author}** — *{work}* ({lang_label}) `{urn}`")
            lines.append("")
        else:
            lines.append("*No classical parallels found (or Scaife unavailable).*")
            lines.append("")

        # --- Academic Literature ---
        lines.append("## Academic Literature")
        lines.append("")
        if self._academic:
            for i, a in enumerate(self._academic, 1):
                title = a.get("title", "")
                authors = a.get("authors", "")
                journal = a.get("journal", "")
                abstract = a.get("abstract", "")
                url = a.get("url", "")
                lines.append(f"### {i}. {title}")
                if authors:
                    lines.append(f"**Authors:** {authors}")
                if journal:
                    lines.append(f"**Journal:** {journal}")
                if abstract:
                    lines.append(f"> {abstract[:500]}")
                if url:
                    lines.append(f"[Full Text]({url})")
                lines.append("")
        else:
            lines.append("*No academic articles found (or DOAJ unavailable).*")
            lines.append("")

        return "\n".join(lines)


# ---------------------------------------------------------------------------
# Module-level helpers
# ---------------------------------------------------------------------------


def _safe_resolve(name: str, resolver_fn, ctx: PassageContext) -> None:
    """Run a resolver function, capturing exceptions as warnings on the context."""
    try:
        resolver_fn()
    except Exception as e:
        ctx.warnings.append(f"{name}: {e}")
    with ctx._lock:
        ctx._resolved.add(name)


def _to_sefaria_ref(reference: str) -> Optional[str]:
    """Convert a Bible reference like 'John 3:16' to Sefaria format 'John.3.16'."""
    import re

    m = re.match(r"\s*(\d*\s*[A-Za-z]+(?:\s+of\s+[A-Za-z]+)?)\s+(\d+):(\d+)", reference)
    if m:
        book = m.group(1).strip().replace(" ", "_")
        chapter = m.group(2)
        verse = m.group(3)
        return f"{book}.{chapter}.{verse}"
    return None


def _extract_book_name(reference: str) -> str:
    """Extract just the book name from a Bible reference. E.g. 'John 3:16' → 'John'."""
    import re

    m = re.match(r"\s*(\d*\s*[A-Za-z]+(?:\s+of\s+[A-Za-z]+)?)", reference)
    if m:
        return m.group(1).strip()
    return reference


def _format_commentary_sbl(hit: dict) -> str:
    """Format a commentary search hit with SBL-style citation."""
    title = hit.get("title", "")
    ref = hit.get("reference", "")
    if title and ref:
        return f"{title} ({ref})"
    elif ref:
        return ref
    return title or "(untitled)"


# ---------------------------------------------------------------------------
# L1 memory cache (module-level lru_cache)
# ---------------------------------------------------------------------------


@lru_cache(maxsize=128)
def _gather_cached(
    reference: str,
    include_rabbinic: bool,
    include_classical: bool,
    include_academic: bool,
) -> str:
    """Cached version of gather+to_markdown.  Keys on (reference, frozenset(options))."""
    # Try disk cache first
    ctx = PassageContext.from_cache(reference)
    if ctx is not None:
        return ctx.to_markdown()

    ctx = PassageContext(reference)
    ctx.gather(
        include_rabbinic=include_rabbinic,
        include_classical=include_classical,
        include_academic=include_academic,
    )
    # Cache to disk for future calls
    ctx.cache_to_disk()
    return ctx.to_markdown()


# ---------------------------------------------------------------------------
# Public convenience function — what MCP tools call
# ---------------------------------------------------------------------------


def study(
    reference: str,
    *,
    include_rabbinic: bool = True,
    include_classical: bool = True,
    include_academic: bool = True,
    use_cache: bool = True,
) -> str:
    """Convenience function: study a passage and return LLM-ready markdown.

    Creates a PassageContext, calls gather(), and returns to_markdown().

    Args:
        reference: Bible reference, e.g. ``"John 3:16"``.
        include_rabbinic: Query Sefaria for rabbinic parallels.
        include_classical: Query Scaife for classical/patristic parallels.
        include_academic: Query DOAJ for academic articles.
        use_cache: Use L1 (memory) and L2 (disk) caches.

    Returns:
        Structured markdown string with all resolved sections.
    """
    if use_cache:
        return _gather_cached(
            reference, include_rabbinic, include_classical, include_academic
        )

    # Fresh fetch — skip all caches, do not persist
    ctx = PassageContext(reference)
    ctx.gather(
        include_rabbinic=include_rabbinic,
        include_classical=include_classical,
        include_academic=include_academic,
    )
    return ctx.to_markdown()
