"""
FastAPI REST API for the Accordance parser library — 25 endpoints.

Provides read-only access to Bible texts, lexicons/dictionaries, commentaries,
and tools. Built on top of `accordance_parser` with module discovery, lazy
loading, and Pydantic response models.
"""

from __future__ import annotations

import time
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING

from cachetools import LRUCache
from fastapi import FastAPI, HTTPException, Query, Depends
from fastapi.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import Response

from accordance_parser.discovery import discover_modules
from accordance_parser.lexicon_module import LexiconModule
from accordance_parser.commentary_module import CommentaryModule
from accordance_parser.text_module import TextModule
from accordance_parser.acc2_module import Acc2Module
from accordance_parser.search_index import _default_db_path

if TYPE_CHECKING:
    from accordance_parser.search_index import SearchIndex
    from accordance_parser.canon import ReferenceResult
    from accordance_parser.lookup_engine import LookupEngine

# ── Pydantic response models ──────────────────────────────────────────────

from pydantic import BaseModel


class LexiconSummary(BaseModel):
    abbreviation: str
    name: str
    language: str
    format: str
    entry_count: int


class TranslationSummary(BaseModel):
    abbreviation: str
    name: str
    language: str


class LexiconLookupResponse(BaseModel):
    module: str
    headword: str
    language: str
    entry: str
    citation: str


class LexiconSearchResponse(BaseModel):
    module: str
    query: str
    results: list[str]
    count: int


class LexiconMetadataResponse(BaseModel):
    module: str
    headword: str
    keys: dict  # Available metadata keys for this headword (varies by module format)
    homograph_count: int
    has_entry: bool


class Acc2ModuleSummary(BaseModel):
    abbreviation: str
    name: str
    paragraph_count: int


class Acc2ParagraphResponse(BaseModel):
    module: str
    paragraph_number: int
    title: str
    text: str


class Acc2Paragraph(BaseModel):
    """A single paragraph entry in an .acc2 module listing."""

    number: int
    title: str
    text: str


class Acc2ParagraphsResponse(BaseModel):
    """Response for listing paragraphs with optional truncation."""

    module: str
    paragraphs: list[Acc2Paragraph]
    truncated: bool = False
    total_count: int


class CommentarySummary(BaseModel):
    abbreviation: str
    name: str
    book_count: int


class CommentarySectionResponse(BaseModel):
    module: str
    reference: str
    text: str


class CommentaryBooksResponse(BaseModel):
    module: str
    books: list[dict]  # {"book": str, "section_count": int}


class CommentarySectionsResponse(BaseModel):
    module: str
    book: str
    chapter: int
    sections: list[dict]  # {"ref": str, "heading": str|None, ...}


class ModuleRegistryResponse(BaseModel):
    bibles: list[TranslationSummary]
    lexicons: list[LexiconSummary]
    commentaries: list[CommentarySummary]
    acc2: list[Acc2ModuleSummary] = []
    total_count: int


class SearchResult(BaseModel):
    """A single search result with source metadata and a snippet."""

    source_type: str
    source_name: str
    source_id: str
    snippet: str
    citation: str
    rank: float


class SearchResponse(BaseModel):
    """Full-text search response."""

    query: str
    results: list[SearchResult]
    total: int


class MorphologyWord(BaseModel):
    """Per-word morphological parsing for a single word in a verse."""

    position: int
    word: str
    strongs: str
    lemma: str
    morph_code: str
    morph_description: str


class MorphologyResponse(BaseModel):
    """Morphology data for all words in a verse."""

    reference: str
    translation: str
    words: list[MorphologyWord]


class LectionaryResponse(BaseModel):
    """Daily Mass lectionary readings for a date."""

    date: str
    season: str
    readings: dict[str, str]


class CanonResolveResponse(BaseModel):
    """Book name resolution result."""

    query: str
    osis: str
    name: str
    testament: str
    position: int | None


class CanonParseResponse(BaseModel):
    """Parsed Bible reference."""

    reference: str
    book_osis: str
    book_name: str
    chapter: int
    verse: int | None
    end_verse: int | None
    canon: str


class CanonBookEntry(BaseModel):
    """A single book entry in a canon listing."""

    osis: str
    name: str
    testament: str
    chapters: int


class CanonBooksResponse(BaseModel):
    """List of books in a canon."""

    canon: str
    book_count: int
    books: list[CanonBookEntry]


class IndexStatusResponse(BaseModel):
    """Search index health and statistics."""

    db_name: str
    ready: bool
    total_rows: int | None = None
    sources: dict | None = None
    message: str | None = None


class LookupResultModel(BaseModel):
    """A single search hit from the LookupEngine pipeline."""

    source_type: str
    source_name: str
    source_id: str
    snippet: str
    citation: str
    rank: float


class LookupResponse(BaseModel):
    """Response from the /api/lookup endpoint."""

    query: str
    language: str
    reversed_form: str | None = None
    lemmas: list[str] = []
    results_by_type: dict[str, list[LookupResultModel]]


class TransliterateResponse(BaseModel):
    """Response from the /api/transliterate endpoint."""

    query: str
    language: str
    reversed_form: str | None = None
    lemmas: list[str] = []


MAX_QUERY_LENGTH = 2048


async def valid_query(
    q: str = Query(
        ...,
        description="Query string (word, phrase, or transliterated form)",
    ),
) -> str:
    """Validate q: non-empty after strip, no null bytes, length ≤ 2048."""
    if not q.strip():
        raise HTTPException(
            status_code=400,
            detail="Query must not be empty or whitespace only",
        )
    if "\x00" in q:
        raise HTTPException(
            status_code=400,
            detail="Query contains invalid null byte",
        )
    if len(q) > MAX_QUERY_LENGTH:
        raise HTTPException(
            status_code=400,
            detail=f"Query exceeds maximum length of {MAX_QUERY_LENGTH} characters",
        )
    return q


class HealthResponse(BaseModel):
    """Simple health check summary."""

    status: str
    modules_loaded: int
    bibles: int
    lexicons: int
    commentaries: int
    acc2: int = 0


class VerseStrongsWord(BaseModel):
    """A single word in a verse with optional Strong's number."""

    text: str
    strongs: str | None = None
    position: int


class VerseResponse(BaseModel):
    """A single verse with text and optional Strong's data."""

    reference: str
    translation: str
    text: str
    strongs: list[str] = []
    words: list[VerseStrongsWord] = []


class VerseRangeResponse(BaseModel):
    """Multiple verses in a range."""

    start: str
    end: str
    translation: str
    verses: list[VerseResponse]


class InterlinearWord(BaseModel):
    """Per-word interlinear entry with Strong's alignment."""

    position: int
    text: str
    strongs: str | None = None


class InterlinearResponse(BaseModel):
    """Word-by-word interlinear data for a verse."""

    reference: str
    translation: str
    words: list[InterlinearWord]


# ── FastAPI app ───────────────────────────────────────────────────────────

app = FastAPI(
    title="Accordance Port REST API",
    description="25 endpoints, read-only access to Bible texts, lexicons, commentaries, and tools. All endpoints are read-only (GET only).",
    version="0.2.0",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

# ── Rate limiting (simple per-IP, no external dependencies) ────────────────

_last_request_time = LRUCache(maxsize=10000)


@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
    """Enforce minimum 100ms between requests from the same client IP."""
    client_ip = request.client.host if request.client else "unknown"
    # Skip rate limiting for localhost and test clients
    if client_ip in ("127.0.0.1", "testclient", "unknown"):
        return await call_next(request)
    now = time.monotonic()
    last = _last_request_time.get(client_ip, 0.0)
    if now - last < 0.1:
        return Response(
            status_code=429,
            content='{"detail":"Too many requests — minimum 100ms between requests"}',
            media_type="application/json",
        )
    _last_request_time[client_ip] = now
    response = await call_next(request)
    return response


# ── Module registry (lazy-loaded instances) ───────────────────────────────

# _modules: dict[str, dict]
#   key = module abbreviation (e.g. "BDAG", "JFB", "NABRE")
#   value = {
#       "instance": LexiconModule | CommentaryModule | TextModule | None,
#       "type": int,         # 1=text, 3=lexicon, 4=hebrew-tool, 5=commentary, 6=generic
#       "human_name": str,
#       "language": str,
#       "format": str,
#       "path": str,
#   }

_modules: dict[str, dict] = {}

# Populate the registry from discovery (no instances loaded yet)
try:
    for abbr, info in discover_modules().items():
        mod_type = info.get("type", 0)
        path = info.get("path", "")
        if not path or not Path(path).exists():
            continue
        _modules[abbr] = {
            "instance": None,
            "type": mod_type,
            "human_name": info.get("human_name", info.get("name", abbr)),
            "language": info.get("language", "en"),
            "format": "",
            "path": path,
        }
except Exception:
    # Graceful degradation if discovery fails (no modules directory)
    pass

# ── Search index (lazy-init on first search request) ────────────────────────

_search_index: SearchIndex | None = None


def _get_search_index() -> SearchIndex:
    """Return the module-level SearchIndex singleton, creating it on first call."""
    global _search_index
    if _search_index is None:
        try:
            from accordance_parser.search_index import SearchIndex

            _search_index = SearchIndex(db_path=None)
            _search_index.ensure_index()
        except Exception as e:
            raise HTTPException(
                status_code=500,
                detail=f"Search index unavailable: {e}",
            )
    return _search_index


# Type 2 modules (English dictionaries like Easton, ISBE) are grouped under
# 'lexicons' because they share the headword-based LexiconModule API.
def _get_lexicon_module(abbreviation: str) -> LexiconModule:
    """Get or lazy-load a LexiconModule instance by abbreviation."""
    entry = _modules.get(abbreviation)
    if entry is None or entry["type"] not in (2, 3, 4):
        raise HTTPException(
            status_code=404,
            detail=f"Lexicon '{abbreviation}' not found in module registry",
        )
    # NOTE: check-then-set is fine for single-event-loop ASGI but not safe
    # for multi-worker deployments with `--workers`.
    if entry["instance"] is None:
        try:
            mod = LexiconModule(entry["path"])
            entry["instance"] = mod
            entry["language"] = mod.language
            entry["format"] = mod.format
            entry["entry_count"] = len(mod.headwords())
        except Exception:
            raise HTTPException(
                status_code=500,
                detail=f"Failed to load lexicon '{abbreviation}'",
            )
    return entry["instance"]


def _get_commentary_module(abbreviation: str) -> CommentaryModule:
    """Get or lazy-load a CommentaryModule instance by abbreviation."""
    entry = _modules.get(abbreviation)
    if entry is None or entry["type"] not in (5, 6):
        raise HTTPException(
            status_code=404,
            detail=f"Commentary '{abbreviation}' not found in module registry",
        )
    # NOTE: check-then-set is fine for single-event-loop ASGI but not safe
    # for multi-worker deployments with `--workers`.
    if entry["instance"] is None:
        try:
            mod = CommentaryModule(entry["path"])
            if mod.skipped:
                mod.close()
                raise HTTPException(
                    status_code=404,
                    detail=f"Commentary '{abbreviation}' is in an unsupported format",
                )
            entry["instance"] = mod
            entry["format"] = mod.format
            entry["book_count"] = len(mod.list_books())
        except HTTPException:
            raise
        except Exception:
            raise HTTPException(
                status_code=500,
                detail=f"Failed to load commentary '{abbreviation}'",
            )
    return entry["instance"]


def _get_acc2_module(abbreviation: str) -> Acc2Module:
    """Get or lazy-load an Acc2Module instance by abbreviation."""
    entry = _modules.get(abbreviation)
    if entry is None or entry["type"] != 15:
        raise HTTPException(
            status_code=404,
            detail=f"Acc2 module '{abbreviation}' not found in module registry",
        )
    # NOTE: check-then-set is fine for single-event-loop ASGI but not safe
    # for multi-worker deployments with `--workers`.
    if entry["instance"] is None:
        try:
            mod = Acc2Module(entry["path"])
            entry["instance"] = mod
            entry["paragraph_count"] = len(mod.entries())
        except Exception:
            raise HTTPException(
                status_code=500,
                detail=f"Failed to load acc2 module '{abbreviation}'",
            )
    return entry["instance"]


# ── Bible endpoints ────────────────────────────────────────────────────────


# ── Helpers ────────────────────────────────────────────────────────────────


def _results_to_search_results(results) -> list[SearchResult]:
    """Convert raw search result tuples to SearchResult models."""
    return [
        SearchResult(
            source_type=r[0],
            source_name=r[1],
            source_id=r[2],
            snippet=r[3],
            citation=r[4],
            rank=r[5],
        )
        for r in results
    ]


def _get_lexicon_summary(abbr: str, entry: dict) -> LexiconSummary:
    """Get a LexiconSummary for a module, caching entry count on first load."""
    if "entry_count" not in entry:
        _get_lexicon_module(abbr)  # loads module and caches entry_count
    return LexiconSummary(
        abbreviation=abbr,
        name=entry["human_name"],
        language=entry.get("language", "unknown"),
        format=entry.get("format", "unknown"),
        entry_count=entry["entry_count"],
    )


def _get_commentary_summary(abbr: str, entry: dict) -> CommentarySummary:
    """Get a CommentarySummary for a module, caching book count on first load."""
    if "book_count" not in entry:
        _get_commentary_module(abbr)  # loads module and caches book_count
    return CommentarySummary(
        abbreviation=abbr,
        name=entry["human_name"],
        book_count=entry["book_count"],
    )


def _get_text_module(translation: str) -> TextModule:
    """Get or lazy-load a TextModule by abbreviation. Raises 404 if not a Bible."""
    entry = _modules.get(translation)
    if entry is None or entry["type"] != 1:
        raise HTTPException(
            status_code=404,
            detail=f"Bible module '{translation}' not found in local registry",
        )
    if entry["instance"] is None:
        try:
            entry["instance"] = TextModule(entry["path"], translation)
        except Exception:
            raise HTTPException(
                status_code=500,
                detail=f"Failed to load module '{translation}'",
            )
    return entry["instance"]


def _parse_ref(ref: str) -> ReferenceResult:
    """Parse a reference string, raising 400 on failure."""
    from accordance_parser.canon import parse_reference

    result = parse_reference(ref)
    if result is None:
        raise HTTPException(
            status_code=400, detail=f"Invalid reference format: '{ref}'"
        )
    return result


def _build_ref_str(parsed: ReferenceResult) -> str:
    """Build a human-readable reference string from a parsed reference."""
    from accordance_parser.canon import BOOKS_BY_OSIS

    book = BOOKS_BY_OSIS.get(parsed.book_osis)
    book_name = book.name if book else parsed.book_osis
    if parsed.verse is not None:
        if parsed.end_verse and parsed.end_verse > parsed.verse:
            return f"{book_name} {parsed.chapter}:{parsed.verse}-{parsed.end_verse}"
        return f"{book_name} {parsed.chapter}:{parsed.verse}"
    return f"{book_name} {parsed.chapter}"


def _split_words(text: str) -> list[str]:
    """Split verse text into word tokens, preserving order."""
    return text.split()


def _build_verse_response(
    text: str,
    reference: str,
    translation: str,
    strongs_list: list[str] | None = None,
    word_strongs: list[list[str]] | None = None,
) -> VerseResponse:
    """Build a VerseResponse from text and optional Strong's data."""
    tokens = _split_words(text)
    strongs_list = strongs_list or []
    words: list[VerseStrongsWord] = []
    for i, token in enumerate(tokens):
        sw = None
        if word_strongs and i < len(word_strongs) and word_strongs[i]:
            sw = word_strongs[i][0]  # first Strong's number
        words.append(VerseStrongsWord(text=token, strongs=sw, position=i + 1))
    return VerseResponse(
        reference=reference,
        translation=translation,
        text=text,
        strongs=strongs_list,
        words=words,
    )


# ── Routes ─────────────────────────────────────────────────────────────────


@app.get("/bibles", response_model=list[TranslationSummary])
async def list_bibles():
    """List all available Bible text modules.

    NOTE: This endpoint returns the complete list with no pagination.
    """
    bibles = []
    for abbr, entry in sorted(_modules.items()):
        if entry["type"] == 1:
            bibles.append(
                TranslationSummary(
                    abbreviation=abbr,
                    name=entry["human_name"],
                    language=entry.get("language", "unknown"),
                )
            )
    return bibles


@app.get("/bibles/{translation}/verses")
async def get_bible_verse(
    translation: str,
    ref: str = Query(..., description="Bible reference, e.g. 'John 3:16'"),
    include_strongs: bool = Query(False, description="Include Strong's numbers data"),
):
    """Get a single verse from a Bible module, with optional Strong's data.

    Tries local TextModule first; falls back to bolls.life API for non-local
    translations.
    """
    # Validate the reference first
    parsed = _parse_ref(ref)
    ref_str = _build_ref_str(parsed)

    # Helper to get Strong's data from a local module
    def _local_strongs_data(mod: TextModule, ref_text: str):
        """Return (strongs_list, word_strongs) from a local TextModule."""
        sl = []
        wl: list[list[str]] = []
        if include_strongs:
            strongs_entries = mod.get_verse_strongs_list(ref_text)
            if strongs_entries:
                for entry in strongs_entries:
                    # entry: (u16_lo, [strongs_strings], word_type)
                    sl.extend(entry[1])
                    wl.append(entry[1])
        return sl, wl

    # 1. Try local TextModule
    entry = _modules.get(translation)
    if entry is not None and entry["type"] == 1:
        try:
            mod = _get_text_module(translation)
            text = mod.get_verse(ref)
            if text and len(text.strip()) > 0:
                sl, wl = _local_strongs_data(mod, ref)
                return _build_verse_response(
                    text=text.strip(),
                    reference=ref_str,
                    translation=translation,
                    strongs_list=sl,
                    word_strongs=wl,
                )
        except HTTPException:
            raise
        except Exception as e:
            raise HTTPException(
                status_code=500,
                detail=f"Error retrieving verse: {e}",
            )

    # 2. Fall back to bolls.life API
    try:
        from accordance_parser.bible_api import get_verse as bolls_get_verse

        result = bolls_get_verse(ref, translation)
    except Exception:
        raise HTTPException(
            status_code=502,
            detail="External Bible API unavailable",
        )

    if result is None:
        raise HTTPException(
            status_code=404,
            detail=f"Verse '{ref}' not found in '{translation}'",
        )

    return _build_verse_response(
        text=result.text,
        reference=ref_str,
        translation=result.translation,
        strongs_list=result.strongs if include_strongs else [],
    )


@app.get("/bibles/{translation}/interlinear")
async def bible_interlinear(
    translation: str,
    ref: str = Query(..., description="Bible reference, e.g. 'John 3:16'"),
) -> InterlinearResponse:
    """Get word-by-word interlinear data with Strong's alignment.

    Requires a local TextModule — bolls.life API does not provide word-level
    Strong's alignment.
    """
    mod = _get_text_module(translation)
    parsed = _parse_ref(ref)
    ref_str = _build_ref_str(parsed)

    text = mod.get_verse(ref)
    if text is None or len(text.strip()) == 0:
        raise HTTPException(
            status_code=404,
            detail=f"Verse '{ref}' not found in '{translation}'",
        )

    # Get word-aligned Strong's data
    strongs_entries = mod.get_verse_strongs_list(ref)
    tokens = _split_words(text.strip())

    words: list[InterlinearWord] = []
    for i, token in enumerate(tokens):
        sw = None
        if strongs_entries and i < len(strongs_entries) and strongs_entries[i][1]:
            sw = strongs_entries[i][1][0]  # first Strong's number
        words.append(InterlinearWord(position=i + 1, text=token, strongs=sw))

    return InterlinearResponse(
        reference=ref_str,
        translation=translation,
        words=words,
    )


@app.get("/bibles/{translation}/verses/range")
async def bible_verse_range(
    translation: str,
    start: str = Query(..., description="Start reference, e.g. 'John 3:16'"),
    end: str = Query(..., description="End reference, e.g. 'John 3:18'"),
    include_strongs: bool = Query(
        False,
        description="Include Strong's numbers and word-level data per verse",
    ),
) -> VerseRangeResponse:
    """Get a range of verses.

    Tries local TextModule for single-chapter ranges (iterates verse-by-verse).
    Falls back to bolls.life API for cross-chapter ranges or non-local
    translations.

    NOTE: When using the bolls.life fallback, Strong's data may not be
    available even with include_strongs=True.
    """
    start_parsed = _parse_ref(start)
    end_parsed = _parse_ref(end)

    if start_parsed.verse is None or end_parsed.verse is None:
        raise HTTPException(
            status_code=400,
            detail="Both start and end references must include a verse number",
        )

    # 1. Try local TextModule for same-chapter, same-book ranges
    entry = _modules.get(translation)
    if (
        entry is not None
        and entry["type"] == 1
        and start_parsed.book_osis == end_parsed.book_osis
        and start_parsed.chapter == end_parsed.chapter
    ):
        try:
            mod = _get_text_module(translation)
            verses: list[VerseResponse] = []
            from accordance_parser.canon import BOOKS_BY_OSIS

            book = BOOKS_BY_OSIS.get(start_parsed.book_osis)
            book_name = book.name if book else start_parsed.book_osis

            for vnum in range(start_parsed.verse, end_parsed.verse + 1):
                vref = f"{book_name} {start_parsed.chapter}:{vnum}"
                text = mod.get_verse(vref)
                if text and len(text.strip()) > 0:
                    if include_strongs:
                        sl: list[str] = []
                        wl: list[list[str]] = []
                        strongs_entries = mod.get_verse_strongs_list(vref)
                        if strongs_entries:
                            for entry in strongs_entries:
                                sl.extend(entry[1])
                                wl.append(entry[1])
                        verses.append(
                            _build_verse_response(
                                text=text.strip(),
                                reference=vref,
                                translation=translation,
                                strongs_list=sl,
                                word_strongs=wl,
                            )
                        )
                    else:
                        verses.append(
                            VerseResponse(
                                reference=vref,
                                translation=translation,
                                text=text.strip(),
                            )
                        )
                else:
                    # Append empty placeholder for missing verses
                    verses.append(
                        VerseResponse(
                            reference=vref,
                            translation=translation,
                            text="",
                        )
                    )

            return VerseRangeResponse(
                start=start,
                end=end,
                translation=translation,
                verses=verses,
            )
        except HTTPException:
            raise
        except Exception as e:
            print(
                f"Local verse range lookup failed for {translation} ({start}-{end}): {e}"
            )
            # Fall through to API fallback

    # 2. Fall back to bolls.life API
    try:
        from accordance_parser.bible_api import get_verse_range as bolls_get_range
        from accordance_parser.canon import BOOKS_BY_OSIS

        # Construct a range reference that includes the end verse for
        # same-chapter ranges, so bolls_get_range can filter properly.
        book = BOOKS_BY_OSIS.get(start_parsed.book_osis)
        book_name = book.name if book else start_parsed.book_osis
        if (
            start_parsed.book_osis == end_parsed.book_osis
            and start_parsed.chapter == end_parsed.chapter
        ):
            range_ref = f"{book_name} {start_parsed.chapter}:{start_parsed.verse}-{end_parsed.verse}"
        else:
            range_ref = start  # cross-chapter: fall back to start reference only
        results = bolls_get_range(range_ref, translation)
    except Exception:
        raise HTTPException(
            status_code=502,
            detail="External Bible API unavailable",
        )

    if not results:
        # Try the end reference directly as a fallback
        try:
            from accordance_parser.bible_api import get_verse as bolls_get_verse

            end_result = bolls_get_verse(end, translation)
            if end_result:
                results = [end_result]
        except Exception:
            pass

    if not results:
        raise HTTPException(
            status_code=404,
            detail=f"Verse range '{start}-{end}' not found in '{translation}'",
        )

    range_verses = [
        VerseResponse(
            reference=r.reference,
            translation=r.translation,
            text=r.text,
            strongs=r.strongs if include_strongs else [],
        )
        for r in results
    ]

    return VerseRangeResponse(
        start=start,
        end=end,
        translation=translation,
        verses=range_verses,
    )


@app.get("/bibles/{translation}/verses/chapter")
async def bible_chapter(
    translation: str,
    ref: str = Query(
        ..., description="Chapter reference, e.g. 'Genesis 1' (no verse number)"
    ),
) -> VerseRangeResponse:
    """Get all verses in a chapter.

    Parses the reference as a chapter (book + chapter number only) and
    returns every verse from verse 1 to the last verse of the chapter.

    Tries local TextModule first; falls back to bolls.life API.
    """
    from accordance_parser.canon import last_verse_of_chapter, BOOKS_BY_OSIS

    # Parse and validate: must be a chapter-level reference (no verse)
    parsed = _parse_ref(ref)
    if parsed.verse is not None:
        raise HTTPException(
            status_code=400,
            detail="Chapter endpoint requires a chapter reference (e.g. 'Genesis 1'), "
            "not a verse reference (e.g. 'Genesis 1:1').",
        )

    book = BOOKS_BY_OSIS.get(parsed.book_osis)
    if book is None:
        raise HTTPException(
            status_code=404,
            detail=f"Book not found for reference: '{ref}'",
        )

    book_name = book.name
    chapter = parsed.chapter
    last_v = last_verse_of_chapter(parsed.book_osis, chapter)
    if last_v == 0:
        raise HTTPException(
            status_code=404,
            detail=f"Chapter {chapter} not found in {book_name}",
        )

    start_ref = f"{book_name} {chapter}:1"
    end_ref = f"{book_name} {chapter}:{last_v}"

    # 1. Try local TextModule
    entry = _modules.get(translation)
    if entry is not None and entry["type"] == 1:
        try:
            mod = _get_text_module(translation)
            verses: list[VerseResponse] = []
            for vnum in range(1, last_v + 1):
                vref = f"{book_name} {chapter}:{vnum}"
                text = mod.get_verse(vref)
                if text and len(text.strip()) > 0:
                    verses.append(
                        VerseResponse(
                            reference=vref,
                            translation=translation,
                            text=text.strip(),
                        )
                    )
                else:
                    verses.append(
                        VerseResponse(
                            reference=vref,
                            translation=translation,
                            text="",
                        )
                    )
            return VerseRangeResponse(
                start=start_ref,
                end=end_ref,
                translation=translation,
                verses=verses,
            )
        except HTTPException:
            raise
        except Exception as e:
            print(f"Local chapter lookup failed for {translation} {ref}: {e}")
            # Fall through to API fallback

    # 2. Fall back to bolls.life API
    try:
        from accordance_parser.bible_api import get_verse_range as bolls_get_range

        results = bolls_get_range(start_ref, translation)
    except Exception:
        raise HTTPException(
            status_code=502,
            detail="External Bible API unavailable",
        )

    if not results:
        raise HTTPException(
            status_code=404,
            detail=f"Chapter '{ref}' not found in '{translation}'",
        )

    return VerseRangeResponse(
        start=start_ref,
        end=end_ref,
        translation=translation,
        verses=[
            VerseResponse(
                reference=r.reference,
                translation=r.translation,
                text=r.text,
            )
            for r in results
        ],
    )


# ── Lexicon endpoints ─────────────────────────────────────────────────────


@app.get("/lexicons", response_model=list[LexiconSummary])
async def list_lexicons() -> list[LexiconSummary]:
    """List all available lexicon/dictionary modules (types 2, 3, and 4).

    NOTE: Returns the complete list with no pagination.
    """
    result = []
    for abbr, entry in sorted(_modules.items()):
        if entry["type"] not in (2, 3, 4):
            continue
        try:
            result.append(_get_lexicon_summary(abbr, entry))
        except Exception:
            continue  # skip modules that fail to load (e.g. skipped/corrupt)
    return result


@app.get("/lexicons/{module}/lookup")
async def lexicon_lookup(
    module: str,
    headword: str = Query(..., description="Headword to look up, e.g. 'λόγος'"),
    summary_level: str = Query(
        default="full",
        description="Truncation level: 'brief', 'standard', or 'full'",
    ),
) -> LexiconLookupResponse:
    """Look up a headword in a specific lexicon."""
    if summary_level not in ("brief", "standard", "full"):
        raise HTTPException(
            status_code=400,
            detail="summary_level must be 'brief', 'standard', or 'full'",
        )
    mod = _get_lexicon_module(module)
    entry_text = mod.lookup(headword, citation=False, summary_level=summary_level)
    if entry_text is None:
        raise HTTPException(
            status_code=404,
            detail=f"Headword '{headword}' not found in {module}",
        )
    citation = f"{module}, s.v. {headword}"
    return LexiconLookupResponse(
        module=module,
        headword=headword,
        language=mod.language,
        entry=entry_text,
        citation=citation,
    )


@app.get("/lexicons/{module}/search")
async def lexicon_search(
    module: str,
    q: str = Query(..., description="Substring to search for in headwords"),
    max_results: int = Query(
        default=20,
        ge=1,
        le=100,
        description="Maximum number of results to return",
    ),
) -> LexiconSearchResponse:
    """Search for headwords matching a query in a specific lexicon."""
    mod = _get_lexicon_module(module)
    results = mod.find(q, max_results=max_results)
    return LexiconSearchResponse(
        module=module,
        query=q,
        results=results,
        count=len(results),
    )


@app.get("/lexicons/{module}/metadata")
async def lexicon_metadata(
    module: str,
    headword: str = Query(..., description="Headword to get metadata for"),
) -> LexiconMetadataResponse:
    """Get structured metadata for a specific headword entry."""
    mod = _get_lexicon_module(module)
    try:
        meta = mod.entry_metadata(headword)
    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail=f"Failed to retrieve metadata: {e}",
        )
    if not meta.get("has_entry", False):
        raise HTTPException(
            status_code=404,
            detail=f"Headword '{headword}' not found in {module}",
        )
    return LexiconMetadataResponse(
        module=module,
        headword=headword,
        keys=meta.get("keys", {}),
        homograph_count=meta.get("homograph_count", 0),
        has_entry=True,
    )


# ── Commentary endpoints ──────────────────────────────────────────────────


@app.get("/commentaries", response_model=list[CommentarySummary])
async def list_commentaries() -> list[CommentarySummary]:
    """List all available commentary modules (types 5 and 6).

    NOTE: Returns the complete list with no pagination.
    """
    result = []
    for abbr, entry in sorted(_modules.items()):
        if entry["type"] not in (5, 6):
            continue
        try:
            result.append(_get_commentary_summary(abbr, entry))
        except Exception:
            continue  # skip modules that fail to load (e.g. skipped/corrupt)
    return result


@app.get("/commentaries/{module}/section")
async def commentary_section(
    module: str,
    ref: str = Query(..., description="Bible reference, e.g. 'John 3:16'"),
) -> CommentarySectionResponse:
    """Get commentary text for a specific Bible reference."""
    mod = _get_commentary_module(module)
    _parse_ref(ref)  # validate reference format early, raises 400 on failure
    try:
        text = mod.get_section(ref)
    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail=f"Error retrieving commentary: {e}",
        )
    if (
        not text
        or text.startswith("[No commentary found")
        or text.startswith("[Invalid section")
    ):
        raise HTTPException(
            status_code=404,
            detail=f"No commentary found for {ref} in {module}",
        )
    return CommentarySectionResponse(
        module=module,
        reference=ref,
        text=text,
    )


@app.get("/commentaries/{module}/books")
async def commentary_books(module: str) -> CommentaryBooksResponse:
    """List all books covered by a commentary module."""
    mod = _get_commentary_module(module)
    book_list = mod.list_books()
    books = [{"book": name, "section_count": count} for name, count in book_list]
    return CommentaryBooksResponse(
        module=module,
        books=books,
    )


@app.get("/commentaries/{module}/sections")
async def commentary_sections(
    module: str,
    book: str = Query(..., description="Canonical book name, e.g. 'Genesis'"),
    chapter: int = Query(..., ge=1, description="Chapter number"),
) -> CommentarySectionsResponse:
    """List all commentary sections for a specific Bible book and chapter."""
    mod = _get_commentary_module(module)
    try:
        sections = mod.list_chapter_sections(book, chapter)
    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail=f"Error listing sections: {e}",
        )
    # Simplify section dicts for the response (omit internal fields)
    clean_sections = []
    for sec in sections:
        clean_sections.append(
            {
                "ref": sec.get("ref", ""),
                "heading": sec.get("heading"),
                "verse_start": sec.get("verse_start"),
                "verse_end": sec.get("verse_end"),
            }
        )
    return CommentarySectionsResponse(
        module=module,
        book=book,
        chapter=chapter,
        sections=clean_sections,
    )


# ── Acc2 endpoints ─────────────────────────────────────────────────────────


@app.get("/acc2", response_model=list[Acc2ModuleSummary])
async def list_acc2() -> list[Acc2ModuleSummary]:
    """List all available .acc2 legacy modules (type 15).

    NOTE: Returns the complete list with no pagination.
    """
    result = []
    for abbr, entry in sorted(_modules.items()):
        if entry["type"] != 15:
            continue
        if "paragraph_count" not in entry:
            _get_acc2_module(abbr)  # loads module and caches paragraph_count
        result.append(
            Acc2ModuleSummary(
                abbreviation=abbr,
                name=entry["human_name"],
                paragraph_count=entry["paragraph_count"],
            )
        )
    return result


@app.get("/acc2/{module}/paragraphs", response_model=Acc2ParagraphsResponse)
async def acc2_paragraphs(
    module: str,
    max_paragraphs: int = Query(
        1000,
        ge=1,
        description="Maximum number of paragraphs to return (truncates if exceeded)",
    ),
):
    """List all paragraphs in an .acc2 module with number, title, and text.

    NOTE: Returns the complete list with no pagination. Use max_paragraphs
    to limit the response size for large modules.
    """
    mod = _get_acc2_module(module)
    entries = mod.entries()
    total = len(entries)
    truncated = total > max_paragraphs
    paragraphs = [
        Acc2Paragraph(number=num, title=title, text=text)
        for num, title, text in entries[:max_paragraphs]
    ]
    return Acc2ParagraphsResponse(
        module=module,
        paragraphs=paragraphs,
        truncated=truncated,
        total_count=total,
    )


@app.get("/acc2/{module}/paragraph/{n}", response_model=Acc2ParagraphResponse)
async def acc2_paragraph(
    module: str,
    n: int,
) -> Acc2ParagraphResponse:
    """Get a specific paragraph from an .acc2 module by number."""
    if n < 1:
        raise HTTPException(
            status_code=400,
            detail="Paragraph number must be >= 1",
        )
    mod = _get_acc2_module(module)
    entries = mod.entries()
    for num, title, text in entries:
        if num == n:
            return Acc2ParagraphResponse(
                module=module,
                paragraph_number=num,
                title=title,
                text=text,
            )
    raise HTTPException(
        status_code=404,
        detail=f"Paragraph {n} not found in acc2 module '{module}'",
    )


# ── Module registry summary endpoint ──────────────────────────────────────


@app.get("/modules")
async def list_all_modules() -> ModuleRegistryResponse:
    """Get a summary of ALL modules: Bibles, lexicons, and commentaries.

    NOTE: This endpoint loads all modules into memory. For lightweight
    discovery without loading, use the individual list endpoints
    (GET /bibles, GET /lexicons, GET /commentaries, GET /acc2).
    """
    bibles = []
    lexicons = []
    commentaries = []
    acc2 = []

    for abbr, entry in sorted(_modules.items()):
        mod_type = entry["type"]
        if mod_type == 1:
            bibles.append(
                TranslationSummary(
                    abbreviation=abbr,
                    name=entry["human_name"],
                    language=entry.get("language", "unknown"),
                )
            )
        elif mod_type in (2, 3, 4):
            try:
                lexicons.append(_get_lexicon_summary(abbr, entry))
            except Exception:
                continue  # skip modules that fail to load
        elif mod_type in (5, 6):
            try:
                commentaries.append(_get_commentary_summary(abbr, entry))
            except Exception:
                continue  # skip modules that fail to load
        elif mod_type == 15:
            if "paragraph_count" not in entry:
                _get_acc2_module(abbr)  # loads module and caches paragraph_count
            acc2.append(
                Acc2ModuleSummary(
                    abbreviation=abbr,
                    name=entry["human_name"],
                    paragraph_count=entry["paragraph_count"],
                )
            )

    total = len(bibles) + len(lexicons) + len(commentaries) + len(acc2)
    return ModuleRegistryResponse(
        bibles=bibles,
        lexicons=lexicons,
        commentaries=commentaries,
        acc2=acc2,
        total_count=total,
    )


# ── Health check endpoint ──────────────────────────────────────────────────


@app.get("/health", response_model=HealthResponse)
async def health_check() -> HealthResponse:
    """Simple health check with loaded module counts."""
    bibles = sum(1 for e in _modules.values() if e["type"] == 1)
    lexicons = sum(1 for e in _modules.values() if e["type"] in (2, 3, 4))
    commentaries = sum(1 for e in _modules.values() if e["type"] in (5, 6))
    acc2 = sum(1 for e in _modules.values() if e["type"] == 15)
    return HealthResponse(
        status="ok",
        modules_loaded=len(_modules),
        bibles=bibles,
        lexicons=lexicons,
        commentaries=commentaries,
        acc2=acc2,
    )


# ── Search endpoints (FTS5 unified search) ─────────────────────────────────


@app.get("/search", response_model=SearchResponse)
async def search(
    q: str = Query(..., description="Search query string"),
    type: str = Query(
        "all", description="Source type: bible, lexicon, commentary, or all"
    ),
    language: str = Query("any", description="Language filter: any, en, el, or he"),
    max_results: int = Query(20, ge=1, le=100, description="Maximum number of results"),
    fuzzy: bool = Query(
        True, description="Enable fuzzy searching (strip diacritics for Greek/Hebrew)"
    ),
) -> SearchResponse:
    """Full-text search across Bibles, lexicons, and commentaries."""
    VALID_TYPES = ("all", "bible", "lexicon", "commentary")
    VALID_LANGUAGES = ("any", "en", "el", "he")
    if type not in VALID_TYPES:
        raise HTTPException(
            status_code=400,
            detail=f"Invalid type '{type}'. Must be one of: {', '.join(VALID_TYPES)}",
        )
    if language not in VALID_LANGUAGES:
        raise HTTPException(
            status_code=400,
            detail=f"Invalid language '{language}'. Must be one of: {', '.join(VALID_LANGUAGES)}",
        )
    idx = _get_search_index()
    results = idx.search(
        q, source_type=type, language=language, max_results=max_results, fuzzy=fuzzy
    )
    sr = _results_to_search_results(results)
    return SearchResponse(query=q, results=sr, total=len(sr))


@app.get("/search/transliterated", response_model=SearchResponse)
async def search_transliterated(
    q: str = Query(
        ..., description="Search query (may be transliterated Greek/Hebrew)"
    ),
    type: str = Query(
        "all", description="Source type: bible, lexicon, commentary, or all"
    ),
    language: str = Query("any", description="Language filter: any, en, el, or he"),
    max_results: int = Query(20, ge=1, le=100, description="Maximum number of results"),
) -> SearchResponse:
    """Search with automatic Greek/Hebrew transliteration support.

    If the query looks like an ASCII transliteration (e.g. 'logos', 'agape'),
    it is reverse-transliterated to native script and searched against
    the original-language text in the index.
    """
    VALID_TYPES = ("all", "bible", "lexicon", "commentary")
    VALID_LANGUAGES = ("any", "en", "el", "he")
    if type not in VALID_TYPES:
        raise HTTPException(
            status_code=400,
            detail=f"Invalid type '{type}'. Must be one of: {', '.join(VALID_TYPES)}",
        )
    if language not in VALID_LANGUAGES:
        raise HTTPException(
            status_code=400,
            detail=f"Invalid language '{language}'. Must be one of: {', '.join(VALID_LANGUAGES)}",
        )
    idx = _get_search_index()
    results = idx.search_transliterated(
        q, source_type=type, language=language, max_results=max_results
    )
    sr = _results_to_search_results(results)
    return SearchResponse(query=q, results=sr, total=len(sr))


# ── Morphology endpoints ───────────────────────────────────────────────────


@app.get("/morphology", response_model=MorphologyResponse)
async def morphology(
    ref: str = Query(..., description="Bible reference, e.g. 'John 3:16'"),
    translation: str = Query(
        "KJV", description="Translation code (for testament detection)"
    ),
) -> MorphologyResponse:
    """Get per-word morphological parsing (Greek/Hebrew) for a verse."""
    # Import separately from data loading for distinct error messages.
    try:
        from accordance_parser.morphology import get_morphology
    except ImportError as e:
        raise HTTPException(
            status_code=503,
            detail=f"Morphology module not available: {e}",
        )
    try:
        entries = get_morphology(ref, translation)
    except FileNotFoundError:
        raise HTTPException(
            status_code=503,
            detail="Morphology data files not found. "
            "Run build_morphology_db.py to generate morphology_gnt.json "
            "and morphology_ot.json in the data/ directory.",
        )
    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail=f"Morphology lookup failed: {e}",
        )
    if not entries:
        raise HTTPException(
            status_code=404,
            detail=f"No morphology data for '{ref}' in {translation}",
        )
    return MorphologyResponse(
        reference=ref,
        translation=translation,
        words=[
            MorphologyWord(
                position=e.word_position,
                word=e.word,
                strongs=e.strongs,
                lemma=e.lemma,
                morph_code=e.morph_code,
                morph_description=e.morph_description,
            )
            for e in entries
        ],
    )


# ── Lectionary endpoints ───────────────────────────────────────────────────


@app.get("/lectionary", response_model=LectionaryResponse)
async def lectionary(
    date: str = Query("", description="Date in YYYY-MM-DD format (default: today)"),
) -> LectionaryResponse:
    """Get Daily Mass (Catholic lectionary) readings for a date."""
    if not date:
        date = datetime.now().date().isoformat()
    from accordance_parser.lectionary import get_lectionary_for_date_string

    result = get_lectionary_for_date_string(date)
    if result is None:
        raise HTTPException(
            status_code=400,
            detail=f"Invalid date format: '{date}'. Use YYYY-MM-DD.",
        )
    season, readings = result
    return LectionaryResponse(date=date, season=season, readings=readings)


# ── Canon endpoints ────────────────────────────────────────────────────────


@app.get("/canon/resolve", response_model=CanonResolveResponse)
async def canon_resolve(
    name: str = Query(
        ..., description="Book name or abbreviation, e.g. 'Genesis', 'Gen', '1Cor'"
    ),
) -> CanonResolveResponse:
    """Resolve a book name (English, Latin, German, or abbreviated) to its canonical identity."""
    from accordance_parser.canon import resolve_book, CANON_PROTESTANT

    book = resolve_book(name)
    if book is None:
        raise HTTPException(
            status_code=404,
            detail=f"Book name '{name}' could not be resolved",
        )
    # Find position in Protestant canon
    position = next(
        (i for i, b in enumerate(CANON_PROTESTANT.books) if b.osis == book.osis),
        None,
    )
    return CanonResolveResponse(
        query=name,
        osis=book.osis,
        name=book.name,
        testament=book.testament,
        position=position,
    )


@app.get("/canon/parse", response_model=CanonParseResponse)
async def canon_parse(
    ref: str = Query(..., description="Bible reference, e.g. 'John 3:16-17'"),
    canon: str = Query(
        "protestant",
        description="Canon system for book resolution: 'protestant' or 'catholic'",
    ),
) -> CanonParseResponse:
    """Parse a Bible reference string into structured components."""
    from accordance_parser.canon import parse_reference, BOOKS_BY_OSIS

    if canon not in ("protestant", "catholic"):
        raise HTTPException(
            status_code=400,
            detail=f"Invalid canon '{canon}'. Must be 'protestant' or 'catholic'.",
        )
    result = parse_reference(ref, canon=canon)
    if result is None:
        raise HTTPException(
            status_code=400,
            detail=f"Invalid reference format: '{ref}'",
        )
    book = BOOKS_BY_OSIS.get(result.book_osis)
    book_name = book.name if book else result.book_osis
    return CanonParseResponse(
        reference=ref,
        book_osis=result.book_osis,
        book_name=book_name,
        chapter=result.chapter,
        verse=result.verse,
        end_verse=result.end_verse,
        canon=canon,
    )


@app.get("/canon/books", response_model=CanonBooksResponse)
async def canon_books(
    canon: str = Query(
        "protestant",
        description="Canon system: 'protestant' (66 books) or 'catholic' (73 books)",
    ),
) -> CanonBooksResponse:
    """List all books in a canon with chapter counts."""
    from accordance_parser.canon import canon_by_name, chapter_count

    try:
        c = canon_by_name(canon)
    except ValueError:
        raise HTTPException(
            status_code=400,
            detail=f"Unknown canon: '{canon}'. Available: protestant, catholic",
        )
    books = [
        CanonBookEntry(
            osis=b.osis,
            name=b.name,
            testament=b.testament,
            chapters=chapter_count(b.osis, c),
        )
        for b in c.books
    ]
    return CanonBooksResponse(canon=c.name, book_count=len(books), books=books)


# ── LookupEngine endpoints ─────────────────────────────────────────────────

_lookup_engine: LookupEngine | None = None


def _get_lookup_engine() -> LookupEngine:
    """Return the module-level LookupEngine singleton, creating it on first call."""
    global _lookup_engine
    if _lookup_engine is None:
        from accordance_parser.lookup_engine import LookupEngine
        from accordance_parser.morphology import _get_db as _get_morph_db

        idx = _get_search_index()
        _lookup_engine = LookupEngine(idx, _get_morph_db())
    return _lookup_engine


@app.get("/api/lookup", response_model=LookupResponse)
async def api_lookup(
    q: str = Depends(valid_query),
    max_results: int = Query(
        10, ge=1, le=50, description="Maximum results per source type"
    ),
) -> LookupResponse:
    """Run the full LookupEngine pipeline: detect language, transliterate, look up lemmas, search."""
    engine = _get_lookup_engine()
    resolved = engine.resolve(q, max_per_type=max_results)

    return LookupResponse(
        query=resolved.original,
        language=resolved.language,
        reversed_form=resolved.reversed_form,
        lemmas=resolved.lemmas,
        results_by_type={
            k: [
                LookupResultModel(
                    source_type=r.source_type,
                    source_name=r.source_name,
                    source_id=r.source_id,
                    snippet=r.snippet,
                    citation=r.citation,
                    rank=r.rank,
                )
                for r in v
            ]
            for k, v in resolved.results_by_type.items()
        },
    )


@app.get("/api/transliterate", response_model=TransliterateResponse)
async def api_transliterate(
    q: str = Depends(valid_query),
) -> TransliterateResponse:
    """Detect language and reverse-transliterate an ASCII form to Greek/Hebrew."""
    engine = _get_lookup_engine()
    # max_per_type=0 skips search — detection + lemmatization only
    resolved = engine.resolve(q, max_per_type=0)

    return TransliterateResponse(
        query=resolved.original,
        language=resolved.language,
        reversed_form=resolved.reversed_form,
        lemmas=resolved.lemmas,
    )


# ── Index management endpoint ─────────────────────────────────────────────


@app.get("/index/status", response_model=IndexStatusResponse)
async def index_status() -> IndexStatusResponse:
    """Get search index health and statistics."""
    global _search_index
    if _search_index is None:
        return IndexStatusResponse(
            db_name=Path(_default_db_path()).name,
            ready=False,
            message="Index not built yet — send a search request or run build_search_index.py",
        )
    status = _search_index.get_status()
    return IndexStatusResponse(
        db_name=Path(status["db_path"]).name,
        ready=status["total_rows"] > 0,
        total_rows=status["total_rows"],
        sources=status.get("sources"),
    )
