"""
Morphology module — original-language word-level parsing (Greek+Habrew).

Provides structured morphology entries, Robinson code descriptions,
Hebrew OSHB/MORPHHB code descriptions, and a lazy-loading MorphologyDB
backed by JSON data files.

Zero external dependencies. Uses stdlib only.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional

from .canon import (
    BOOKS_BY_OSIS,
    parse_reference as canon_parse_reference,
)
from .search_index import strip_diacritics

# ---------------------------------------------------------------------------
# 1. Data structures
# ---------------------------------------------------------------------------


@dataclass
class MorphologyEntry:
    """Morphological parsing for a single word in a verse."""

    word: str  # Original language word (Greek/Hebrew Unicode)
    strongs: str  # e.g. "G3056", "H7225"
    lemma: str  # Dictionary/lexical form (or "" if unknown)
    morph_code: str  # Raw morphology code (e.g. "V-AAI-3S", "HR/Ncfsa")
    morph_description: str  # Human-readable description
    word_position: int  # 1-indexed position in verse


# ---------------------------------------------------------------------------
# 2. Robinson code descriptions (Greek NT)
# ---------------------------------------------------------------------------

# Parts of speech (first char, or first segment before '-')
_ROB_POS: Dict[str, str] = {
    "N": "Noun",
    "V": "Verb",
    "A": "Adjective",
    "P": "Preposition",
    "D": "Adverb",
    "C": "Conjunction",
    "R": "Relative Pronoun",
    "T": "Article",
    "I": "Interjection",
    "X": "Particle",
    "S": "Demonstrative Pronoun",
    "F": "Reflexive Pronoun",
    "Q": "Correlative/Interrogative Pronoun",
    "K": "Correlative Adverb",
}

_ROB_CASE: Dict[str, str] = {
    "N": "Nominative",
    "G": "Genitive",
    "D": "Dative",
    "A": "Accusative",
    "V": "Vocative",
}

_ROB_GENDER: Dict[str, str] = {
    "M": "Masculine",
    "F": "Feminine",
    "N": "Neuter",
}

_ROB_NUMBER: Dict[str, str] = {
    "S": "Singular",
    "P": "Plural",
}

_ROB_TENSE: Dict[str, str] = {
    "P": "Present",
    "I": "Imperfect",
    "F": "Future",
    "A": "Aorist",
    "X": "Perfect",
    "Y": "Pluperfect",
    "R": "2nd Perfect",
}

_ROB_VOICE: Dict[str, str] = {
    "A": "Active",
    "M": "Middle",
    "P": "Passive",
    "D": "Middle Deponent",
    "O": "Passive Deponent",
    "N": "Middle/Passive Deponent",
    "E": "Middle or Passive",
    "Q": "Impersonal Active",
}

_ROB_MOOD: Dict[str, str] = {
    "I": "Indicative",
    "D": "Imperative",
    "S": "Subjunctive",
    "O": "Optative",
    "N": "Infinitive",
    "P": "Participle",
    "M": "Imperative Middle",
}

_ROB_PERSON: Dict[str, str] = {
    "1": "1st",
    "2": "2nd",
    "3": "3rd",
}

_ROB_DEGREE: Dict[str, str] = {
    "C": "Comparative",
    "S": "Superlative",
}

_ROB_SUFFIX: Dict[str, str] = {
    "C": " (Comparative)",
    "S": " (Superlative)",
    "N": " (Negative)",
    "P": " (Proper)",
    "T": " (Transliterated)",
    "I": " (Interrogative)",
    "K": " (Correlative)",
}


def describe_robinson(code: str) -> str:
    """Convert a Robinson morphological code to a human-readable description.

    Handles nouns, verbs, adjectives, adverbs, articles, prepositions,
    conjunctions, particles, pronouns, and interjections.

    Robinson noun/adjective/article/pronoun code order:
      Case-Number-Gender  (e.g. NSF = Nominative Singular Feminine)

    Verb code order:
      Tense-Voice-Mood[-Person-Number]
      Tense/Voice/Mood are packed into one segment (e.g. "AAI" = Aorist Active Indicative)

    Args:
        code: Robinson code string, e.g. "N-NSF", "V-AAI-3S", "ADV"

    Returns:
        Human-readable description, e.g. "Noun - Nominative Singular Feminine"
    """
    if not code:
        return "(none)"

    # --- Unsegmented short forms ---
    if code == "ADV":
        return "Adverb"
    if code == "ADV-C":
        return "Adverb (Comparative)"
    if code == "ADV-S":
        return "Adverb (Superlative)"
    if code == "ADV-K":
        return "Adverb (Correlative)"
    if code == "ADV-I":
        return "Adverb (Interrogative)"
    if code == "ADV-N":
        return "Adverb (Negative)"
    if code == "CONJ":
        return "Conjunction"
    if code == "CONJ-C":
        return "Conjunction (Conditional)"
    if code == "CONJ-K":
        return "Conjunction (Correlative)"
    if code == "CONJ-N":
        return "Conjunction (Negative)"
    if code == "PREP":
        return "Preposition"
    if code == "PRT":
        return "Particle"
    if code == "PRT-I":
        return "Particle (Interrogative)"
    if code == "PRT-N":
        return "Particle (Negative)"
    if code == "I":
        return "Interjection"
    if code == "X":
        return "Particle"

    # --- Detect suffixes like -C, -S, -N, -T, -I, -K ---
    # Suffixes appear at the end, separated by '-'.  Only detect when
    # there are 2+ segments (bare "K" or "D" should not be stripped).
    suffix_desc = ""
    segments = code.split("-")
    if len(segments) >= 2:
        last = segments[-1]
        if len(last) == 1 and last in "CSNITK":
            # "P" is ambiguous: "Plural" number vs "(Proper)" suffix.
            # Keep "P" in the main code; only strip C, S, N, I, T, K.
            if last != "P":
                suffix_desc = _ROB_SUFFIX.get(last, "")
                segments = segments[:-1]

    if not segments:
        return f"{code} (unknown)"

    pos = segments[0]
    if pos not in _ROB_POS:
        # Handle 2-char part-of-speech like "ADV" that was missed above
        if code.startswith("ADV"):
            return f"Adverb{suffix_desc}"
        return f"{code} (unknown)"

    pos_name = _ROB_POS[pos]

    # --- Noun / Adjective / Article / Pronoun types: Case-Number-Gender ---
    # Robinson order: first char = Case, second char = Number, third char = Gender
    if pos in ("N", "A", "R", "S", "F", "Q", "T"):
        if len(segments) >= 2 and len(segments[1]) >= 3:
            sub = segments[1]
            case_val = _ROB_CASE.get(sub[0], sub[0])
            num_val = _ROB_NUMBER.get(sub[1], sub[1])
            gend_val = _ROB_GENDER.get(sub[2], sub[2])
            if suffix_desc:
                return f"{pos_name} - {case_val} {num_val} {gend_val}{suffix_desc}"
            return f"{pos_name} - {case_val} {num_val} {gend_val}"
        return f"{code} (unknown)"

    # --- Verb: Tense-Voice-Mood packed in seg[1], Person-Number in seg[2] ---
    if pos == "V":
        if len(segments) >= 2:
            tvm = segments[1]

            # Handle leading modifiers: "2" (2nd Aorist), "R" (2nd Perfect)
            tvm_modifier = ""
            if len(tvm) >= 4 and tvm[0] in ("2", "R"):
                if tvm[0] == "2":
                    tvm_modifier = "2nd "
                elif tvm[0] == "R":
                    tvm_modifier = "2nd "
                tvm = tvm[1:]

            if len(tvm) >= 3:
                tense_val = _ROB_TENSE.get(tvm[0], tvm[0])
                voice_val = _ROB_VOICE.get(tvm[1], tvm[1])
                mood_val = _ROB_MOOD.get(tvm[2], tvm[2])

                if len(segments) >= 3 and len(segments[2]) >= 2:
                    pn = segments[2]
                    pers_val = _ROB_PERSON.get(pn[0], pn[0])
                    num_val = _ROB_NUMBER.get(pn[1], pn[1])
                    return f"{pos_name} - {tvm_modifier}{tense_val} {voice_val} {mood_val} {pers_val} {num_val}"

                return f"{pos_name} - {tvm_modifier}{tense_val} {voice_val} {mood_val}"

        return f"{code} (unknown)"

    # --- Preposition: P-XXX (case taken) or just PREP ---
    if pos == "P":
        if len(segments) >= 2:
            case_val = _ROB_CASE.get(segments[1], segments[1])
            return f"Preposition (+{case_val})"
        return "Preposition"

    # --- Correlative Adverb ---
    if pos == "K":
        return "Correlative Adverb"

    # --- D (adverb) ---
    if pos == "D":
        if suffix_desc:
            return f"Adverb{suffix_desc}"
        return "Adverb"

    return f"{code} (unknown)"


# ---------------------------------------------------------------------------
# 3. Hebrew morphology code descriptions (OSHB/MORPHHB format)
# ---------------------------------------------------------------------------

_HEB_STEM: Dict[str, str] = {
    "q": "Qal",
    "n": "Niphal",
    "p": "Piel",
    "u": "Pual",
    "h": "Hiphil",
    "o": "Hophal",
    "t": "Hithpael",
    "r": "Hithpolel",
    "m": "Polel",
    "l": "Pilpel",
}

_HEB_POS: Dict[str, str] = {
    "N": "Noun",
    "V": "Verb",
    "P": "Pronoun",
    "D": "Adverb",
    "C": "Conjunction",
    "T": "Particle",
    "R": "Preposition",
    "A": "Article",
    "S": "Suffix",
    "I": "Interjection",
}

_HEB_GENDER: Dict[str, str] = {
    "m": "masculine",
    "f": "feminine",
    "c": "common",
}

_HEB_NUMBER: Dict[str, str] = {
    "s": "singular",
    "p": "plural",
    "d": "dual",
}

_HEB_STATE: Dict[str, str] = {
    "a": "absolute",
    "c": "construct",
    "d": "determined",
    "e": "emphatic",
}

_HEB_PERSON: Dict[str, str] = {
    "1": "1st",
    "2": "2nd",
    "3": "3rd",
}

_HEB_TENSE: Dict[str, str] = {
    "p": "Perfect",
    "i": "Imperfect",
    "v": "Imperative",
    "a": "Infinitive Absolute",
    "c": "Infinitive Construct",
    "r": "Participle",
}


def describe_hebrew_morph(code: str) -> str:
    """Convert an OSHB/MORPHHB Hebrew morphology code to a readable description.

    Parses codes like "HR/Ncfsa" or "HVqp3ms".  Strips Hebrew prefix
    markers and interprets the remaining code by scanning for known
    morphological features.

    Args:
        code: Morphology code string, e.g. "HR/Ncfsa", "HVqp3ms"

    Returns:
        Human-readable description, e.g. "Noun - feminine singular absolute"
    """
    if not code:
        return "(none)"

    # --- Strip Hebrew prefix markers ---
    # Three forms:
    #   1. "HR/Ncfsa", "HC/Vqp3ms" → strip everything before '/'
    #   2. "HVqp3ms" → H + non-POS prefix char + verb form (no POS letter)
    #   3. "HNcfsa", "H/Ncfsa" → H + POS letter + rest

    working = code

    if "/" in working:
        slash_idx = working.index("/")
        working = working[slash_idx + 1 :]
    elif working.startswith("H") and len(working) >= 2:
        # Strip the initial "H"
        working = working[1:]
        # Strip one more char if it's a non-POS prefix letter (R, V, C)
        # These are common prefix markers, not part-of-speech indicators
        non_pos_prefixes = frozenset("RVC")
        if working and working[0] in non_pos_prefixes:
            # Make sure we're not stripping a POS letter (N, V, P, D, etc.)
            # If the remaining code after H+R/C/V has lowercase first, the
            # second char really was a prefix marker
            if len(working) >= 2 and working[1].islower():
                working = working[1:]
            # Special case: "HRd" → "d" (demonstrative, single lowercase)
            elif len(working) == 2 and working[1].islower():
                working = working[1:]

    if not working:
        return f"{code} (unknown)"

    # --- Determine part of speech ---
    pos_key = working[0]
    if pos_key in _HEB_POS:
        pos_name = _HEB_POS[pos_key]
        rest = working[1:]
        return _hebrew_parse_rest(pos_key, pos_name, rest, code)
    else:
        # No uppercase POS — all-lowercase code like "d", "qp3ms"
        return _hebrew_lowercase(working, code)


def _hebrew_parse_rest(pos_key: str, pos_name: str, rest: str, full_code: str) -> str:
    """Parse the remainder of a Hebrew morphology code after the POS."""
    if pos_key == "N":
        # Noun: after N, optionally skip "c" (consonantal marker).
        # Then scan for gender (f/m), number (s/p/d), state (a/c/d/e).
        if rest and rest[0] == "c":
            rest = rest[1:]

        features: List[str] = []
        for ch in rest:
            if ch in _HEB_GENDER:
                features.append(_HEB_GENDER[ch])
            elif ch in _HEB_NUMBER:
                features.append(_HEB_NUMBER[ch])
            elif ch in _HEB_STATE:
                features.append(_HEB_STATE[ch])
        if features:
            return f"{pos_name} - {' '.join(features)}"
        return pos_name

    if pos_key == "V":
        # Verb: rest contains stem + tense + person + gender + number
        return _hebrew_scan_verb(pos_name, rest, full_code)

    # Other uppercase POS: simple label
    return pos_name


def _hebrew_scan_verb(pos_name: str, rest: str, full_code: str) -> str:
    """Scan a Hebrew verb remainder for morphological features.

    Order: stem (first lowercase letter), then tense/person/gender/number.
    Avoids re-matching stems after the first one.
    """
    features: List[str] = []
    stem_found = False
    tense_found = False
    person_found = False
    gender_found = False
    number_found = False

    for ch in rest:
        # Stem: only the first lowercase letter that matches a known stem
        if not stem_found and ch in _HEB_STEM:
            features.append(_HEB_STEM[ch])
            stem_found = True
            continue

        # The rest: prefer tense > person > gender > number over stems
        if stem_found:
            if not tense_found and ch in _HEB_TENSE:
                features.append(_HEB_TENSE[ch])
                tense_found = True
                continue
            if not person_found and ch.isdigit():
                features.append(_HEB_PERSON.get(ch, ch))
                person_found = True
                continue
            if not gender_found and ch in _HEB_GENDER:
                features.append(_HEB_GENDER[ch])
                gender_found = True
                continue
            if not number_found and ch in _HEB_NUMBER:
                features.append(_HEB_NUMBER[ch])
                number_found = True
                continue
        else:
            # No stem yet; try non-stem features anyway (rare edge case)
            if not tense_found and ch in _HEB_TENSE:
                features.append(_HEB_TENSE[ch])
                tense_found = True
                continue
            if not person_found and ch.isdigit():
                features.append(_HEB_PERSON.get(ch, ch))
                person_found = True
                continue
            if not gender_found and ch in _HEB_GENDER:
                features.append(_HEB_GENDER[ch])
                gender_found = True
                continue
            if not number_found and ch in _HEB_NUMBER:
                features.append(_HEB_NUMBER[ch])
                number_found = True
                continue

    if features:
        return f"{pos_name} - {' '.join(features)}"
    return pos_name


def _hebrew_lowercase(working: str, full_code: str) -> str:
    """Handle codes with no uppercase POS marker (all lowercase after prefix strip).

    Examples: "d" → Demonstrative Pronoun, "qp3ms" → Verb form.
    """
    # Map single lowercase chars to part-of-speech meanings
    lowercase_pos = {
        "d": "Demonstrative Pronoun",
        "a": "Article",
        "r": "Preposition",
        "c": "Conjunction",
        "p": "Pronoun",
        "t": "Particle",
        "i": "Interjection",
    }

    # Single-char codes
    if len(working) == 1 and working in lowercase_pos:
        return lowercase_pos[working]

    # Multi-char lowercase: try verb scanning first (starts with stem letter)
    if working and working[0] in _HEB_STEM:
        return _hebrew_scan_verb("Verb", working, full_code)

    # Fallback: scan for any known features
    features: List[str] = []
    for ch in working:
        if ch in lowercase_pos:
            features.append(lowercase_pos[ch])
        elif ch in _HEB_GENDER:
            features.append(_HEB_GENDER[ch])
        elif ch in _HEB_NUMBER:
            features.append(_HEB_NUMBER[ch])

    if features:
        return " - ".join(features)
    return f"{full_code} (unknown)"


# ---------------------------------------------------------------------------
# 4. MorphologyDB — lazy-loading JSON-backed morphology DB
# ---------------------------------------------------------------------------


class MorphologyDB:
    """Loads and queries morphology data files (Greek NT, Hebrew OT).

    Lazy-loads JSON files on first access. Thread-safe for MCP server use.
    """

    def __init__(self, data_dir: str | None = None):
        """Create a MorphologyDB.

        Args:
            data_dir: Directory containing morphology JSON files.
                      Defaults to <project_root>/data/.
        """
        if data_dir is None:
            data_dir = str(Path(__file__).resolve().parent.parent / "data")
        self._data_dir = Path(data_dir)
        self._gnt_data: Optional[Dict[str, list]] = None
        self._ot_data: Optional[Dict[str, list]] = None
        self._lemma_index: Optional[Dict[str, set[str]]] = None

    def _load_gnt(self) -> Dict[str, list]:
        """Load Greek NT morphology data, once."""
        if self._gnt_data is not None:
            return self._gnt_data
        path = self._data_dir / "morphology_gnt.json"
        if not path.exists():
            self._gnt_data = {}
            return self._gnt_data
        with open(str(path), "r", encoding="utf-8") as f:
            data = json.load(f)
        self._gnt_data = data.get("verses", {})
        return self._gnt_data

    def _load_ot(self) -> Dict[str, list]:
        """Load Hebrew OT morphology data, once."""
        if self._ot_data is not None:
            return self._ot_data
        path = self._data_dir / "morphology_ot.json"
        if not path.exists():
            self._ot_data = {}
            return self._ot_data
        with open(str(path), "r", encoding="utf-8") as f:
            data = json.load(f)
        self._ot_data = data.get("verses", {})
        return self._ot_data

    def _build_lemma_index(self) -> None:
        """Build inflected-form → lemmas mapping, loading data lazily.

        Built once on first call.  Iterates both Greek NT and Hebrew OT
        entries.  Keys are normalised (NFD-decomposed diacritics
        stripped, lowercased) so lookups are accent- and case-insensitive
        — matching the behaviour of the replaced O(n) scan.

        Entries with an empty lemma (all Hebrew in current source data)
        are silently skipped.
        """
        if self._lemma_index is not None:
            return
        index: Dict[str, set[str]] = {}
        # Greek NT
        for entries in self._load_gnt().values():
            for entry in entries:
                if len(entry) < 3:
                    continue
                word = entry[0]
                lemma = entry[2]
                if not lemma:
                    continue
                key = strip_diacritics(word).lower()
                if key not in index:
                    index[key] = {lemma}
                else:
                    index[key].add(lemma)
        # Hebrew OT
        for entries in self._load_ot().values():
            for entry in entries:
                if len(entry) < 3:
                    continue
                word = entry[0]
                lemma = entry[2]
                if not lemma:
                    continue  # lemmas populated via Strong's enrichment (commit 2185ba4)
                key = strip_diacritics(word).lower()
                if key not in index:
                    index[key] = {lemma}
                else:
                    index[key].add(lemma)
                # Hebrew OSHB morphology uses slash-separated morpheme markers
                # (e.g. "ב/ראשית"). Also index the root-only form and the
                # slash-free form so that bare queries like "בראשית" match.
                if "/" in word:
                    root_only = word.rsplit("/", 1)[-1]
                    key2 = strip_diacritics(root_only).lower()
                    if key2 and key2 != key:
                        if key2 not in index:
                            index[key2] = {lemma}
                        else:
                            index[key2].add(lemma)
                    # Also index the form with slash removed entirely
                    no_slash = word.replace("/", "")
                    key3 = strip_diacritics(no_slash).lower()
                    if key3 and key3 != key and key3 != key2:
                        if key3 not in index:
                            index[key3] = {lemma}
                        else:
                            index[key3].add(lemma)
                # Hebrew common prefix stripping: many inflected forms carry
                # prefix letters (ה=definite article, ו=conjunction,
                # ב/ל/מ/כ=prepositions) without a slash separator.
                # Index the root without the prefix so that bare queries
                # like "ארץ" match inflected "הארץ".
                COMMON_PREFIXES = "הובלמכ"
                if len(key) >= 3 and key[0] in COMMON_PREFIXES:
                    stripped = key[1:].lstrip("/")
                    if stripped and stripped not in index:
                        index[stripped] = {lemma}
                    elif stripped:
                        index[stripped].add(lemma)
        self._lemma_index = index

    def lemma_from_form(self, word: str, lang: str) -> List[str]:
        """Return lemmas for an inflected form.

        Builds the form→lemmas index lazily on first call.  The lookup is
        accent- and case-insensitive (diacritics stripped via NFD
        decomposition, lowercased).  Hebrew lemmas are populated from
        Strong's enrichment data (commit 2185ba4) with 97.4% coverage.
        OSHB slash-separated morpheme markers are handled automatically.
        Common Hebrew prefixes (ה,ו,ב,ל,מ,כ) are stripped on lookup fallback.

        Args:
            word: Inflected form (Greek or Hebrew Unicode).
            lang: Language code — ``'greek'``, ``'hebrew'``,
                  ``'el'``, or ``'he'``.

        Returns:
            Sorted list of matching lemma strings, or [].
        """
        self._build_lemma_index()
        key = strip_diacritics(word).lower()
        result = self._lemma_index.get(key)
        if result:
            return sorted(result)
        # Hebrew: try stripping common prefix letters (ה,ו,ב,ל,מ,כ)
        if lang in ("hebrew", "he") and len(key) >= 3 and key[0] in "הובלמכ":
            stripped = key[1:].lstrip("/")
            result = self._lemma_index.get(stripped)
            if result:
                return sorted(result)
        return []

    def get_verse_morphology(
        self,
        book_id: int,
        chapter: int,
        verse: int,
        testament: str | None = None,
    ) -> List[MorphologyEntry]:
        """Get morphology for all words in a verse.

        Args:
            book_id: bolls book ID (1-66).
            chapter: Chapter number.
            verse: Verse number.
            testament: 'ot' or 'nt' — if None, auto-detect from book_id.

        Returns:
            List of MorphologyEntry objects, one per word. Empty list if not found.
        """
        if testament is None:
            testament = "ot" if book_id <= 39 else "nt"

        key = f"{book_id}_{chapter}_{verse}"

        if testament == "nt":
            data = self._load_gnt()
            describe_fn = describe_robinson
        else:
            data = self._load_ot()
            describe_fn = describe_hebrew_morph

        raw_entries = data.get(key, [])

        results: List[MorphologyEntry] = []
        for i, entry in enumerate(raw_entries):
            if len(entry) < 4:
                continue
            word = entry[0]
            strongs = entry[1]
            lemma = entry[2]
            morph_code = entry[3]
            morph_desc = describe_fn(morph_code)

            results.append(
                MorphologyEntry(
                    word=word,
                    strongs=strongs,
                    lemma=lemma,
                    morph_code=morph_code,
                    morph_description=morph_desc,
                    word_position=i + 1,
                )
            )

        return results


# ---------------------------------------------------------------------------
# 5. Module-level singleton (lazy-init on first use)
# ---------------------------------------------------------------------------

_morph_db: Optional[MorphologyDB] = None


def _get_db() -> MorphologyDB:
    """Return the module-level MorphologyDB singleton."""
    global _morph_db
    if _morph_db is None:
        _morph_db = MorphologyDB()
    return _morph_db


# ---------------------------------------------------------------------------
# 6. Public convenience function
# ---------------------------------------------------------------------------


def get_morphology(reference: str, translation: str = "KJV") -> List[MorphologyEntry]:
    """Return structured morphology for each word in a verse.

    Parses the Bible reference, resolves to a bolls book ID, determines
    testament, and loads morphology from the appropriate source (OpenGNT
    for NT, OpenScriptures Hebrew Bible for OT).

    Args:
        reference: Bible reference (e.g. "John 3:16", "Genesis 1:1")
        translation: Translation code (e.g. "KJV", "ESV").
                     Used for testament detection; morphology always
                     comes from original language sources.

    Returns:
        List of MorphologyEntry objects, one per word. Empty list on error.
    """
    # Parse reference
    result = canon_parse_reference(reference, canon="protestant")
    if result is None:
        return []

    book_osis = result.book_osis
    chapter = result.chapter
    verse_num = result.verse
    if verse_num is None:
        return []  # Chapter-only references not supported

    # Get bolls book ID
    book = BOOKS_BY_OSIS.get(book_osis)
    if book is None or book.bolls_id is None:
        return []

    book_id = book.bolls_id

    # Determine testament
    if book.testament == "nt":
        testament = "nt"
    else:
        testament = "ot"

    db = _get_db()
    return db.get_verse_morphology(book_id, chapter, verse_num, testament)


# ---------------------------------------------------------------------------
# 7. MACULA enrichment — semantic roles, participant referents, word senses
# ---------------------------------------------------------------------------

# PropBank-style role codes → readable names
_PROPBANK_ROLE: Dict[str, str] = {
    "A0": "Agent",
    "A1": "Patient",
    "A2": "Instrument/Beneficiary",
    "A3": "Start Point",
    "A4": "End Point",
    "AM-LOC": "Location",
    "AM-TMP": "Temporal",
    "AM-MNR": "Manner",
    "AM-CAU": "Cause",
    "AM-PNC": "Purpose",
    "AM-NEG": "Negation",
    "AM-DIR": "Direction",
    "AM-EXT": "Extent",
    "AM-ADV": "Adverbial",
    "AM-DIS": "Discourse",
    "AM-MOD": "Modal",
    "AM-PRD": "Predicate",
}


def _get_macula_reader():
    """Lazy-import MaculaReader. Returns instance or None if unavailable."""
    try:
        from .macula_reader import MaculaReader

        return MaculaReader()
    except ImportError:
        return None


def _macula_xml_id(word: Dict[str, Any]) -> str:
    """Get xml:id from a MACULA word dict (handles namespace variants)."""
    return word.get("xml_id", word.get("nodeId", ""))


def _macula_lookup_word(words: List[Dict[str, Any]], target_id: str) -> Optional[str]:
    """Find a word's text by its xml:id."""
    for w in words:
        if _macula_xml_id(w) == target_id:
            return w.get("Unicode", "").rstrip(",.;·") or w.get("Gloss", "")
    return None


def _build_role_map(macula_words: List[Dict[str, Any]]) -> Dict[str, str]:
    """Build xml_id → role mapping from MACULA Frame attributes.

    Verb nodes carry a ``Frame`` attribute like ``"A0:n123 A1:n456"``,
    where A0=Agent, A1=Patient, etc.  This function extracts every
    role→target mapping across all verbs in the verse.
    """
    role_map: Dict[str, str] = {}
    for w in macula_words:
        frame = w.get("Frame", "")
        if not frame:
            continue
        for part in frame.split():
            if ":" not in part:
                continue
            role_code, target_id = part.split(":", 1)
            role_name = _PROPBANK_ROLE.get(role_code, role_code)
            role_map[target_id] = role_name
    return role_map


def _build_participant_map(
    macula_words: List[Dict[str, Any]],
) -> Dict[str, str]:
    """Build xml_id → referent_text mapping from MACULA Ref attributes.

    Pronouns carry a ``Ref`` attribute pointing to another word's xml:id
    (the referent / antecedent).
    """
    part_map: Dict[str, str] = {}
    for w in macula_words:
        ref_target = w.get("Ref", "")
        if not ref_target:
            continue
        pronoun_id = _macula_xml_id(w)
        referent = _macula_lookup_word(macula_words, ref_target)
        if referent:
            part_map[pronoun_id] = referent
    return part_map


def _build_sense_map(
    macula_words: List[Dict[str, Any]], language: str
) -> Dict[str, Dict[str, str]]:
    """Build xml_id → {domain, lexicon_entry} mapping for word senses.

    Greek: ``LexDomain`` + ``LN`` (Louw & Nida).
    Hebrew: ``domain`` + ``SDBH`` (Semantic Dictionary of Biblical Hebrew).
    """
    sense_map: Dict[str, Dict[str, str]] = {}
    for w in macula_words:
        xml_id = _macula_xml_id(w)
        if language == "greek":
            domain = w.get("LexDomain", "")
            lex_entry = w.get("LN", "")
        else:
            domain = w.get("LexDomain", w.get("domain", ""))
            lex_entry = w.get("LN", w.get("SDBH", ""))
        if domain or lex_entry:
            sense_map[xml_id] = {"domain": domain, "lexicon_entry": lex_entry}
    return sense_map


def _morph_to_dict(entry: MorphologyEntry) -> Dict[str, Any]:
    """Serialize a MorphologyEntry to a plain dict with enrichment slots."""
    return {
        "word": entry.word,
        "strongs": entry.strongs,
        "lemma": entry.lemma,
        "morph_code": entry.morph_code,
        "morph_description": entry.morph_description,
        "word_position": entry.word_position,
        "semantic_role": None,
        "participant": None,
        "word_sense": {},
    }


def enrich_morphology(reference: str, language: str = "greek") -> Dict[str, Any]:
    """Return morphology enriched with MACULA semantic roles and referents.

    First calls :func:`get_morphology` for base morphology, then attempts
    to merge in:
      - **semantic_role**  — Agent, Patient, Location, etc. (PropBank labels)
      - **participant**    — who a pronoun refers to (MACULA Ref chain)
      - **word_sense**     — Louw & Nida (Greek) or SDBH (Hebrew) domain/entry

    MACULA integration is **optional**: if the data is not downloaded or
    the ``macula_reader`` module is unavailable, enrichment slots are left
    empty and the base morphology is returned as-is (no crash, no error).

    Args:
        reference: Bible reference, e.g. ``"John 1:3"``, ``"Genesis 1:1"``.
        language: ``"greek"`` (default, also accepts ``"el"``) or
                  ``"hebrew"`` (also accepts ``"he"``).

    Returns:
        Dict with keys:
          - ``reference`` — input reference string
          - ``language`` — normalised language code
          - ``macula_available`` — bool, whether enrichment was applied
          - ``words`` — list of enriched word dicts:
            ``{word, strongs, lemma, morph_code, morph_description,
               word_position, semantic_role, participant, word_sense}``
    """
    # Normalise language tag
    lang = "greek" if language in ("greek", "el") else "hebrew"

    # Base morphology (always available)
    base: List[MorphologyEntry] = get_morphology(reference)

    # Start with base dicts
    words: List[Dict[str, Any]] = [_morph_to_dict(m) for m in base]

    if not base:
        return {
            "reference": reference,
            "language": lang,
            "macula_available": False,
            "words": words,
        }

    # --- Try MACULA enrichment ---
    reader = _get_macula_reader()
    if reader is None or not reader.available():
        return {
            "reference": reference,
            "language": lang,
            "macula_available": False,
            "words": words,
        }

    # Check language-specific availability
    if lang == "greek" and not reader.greek_available():
        return {
            "reference": reference,
            "language": lang,
            "macula_available": False,
            "words": words,
        }
    if lang == "hebrew" and not reader.hebrew_available():
        return {
            "reference": reference,
            "language": lang,
            "macula_available": False,
            "words": words,
        }

    try:
        err, _tree, macula_words = reader._resolve_and_parse(reference, lang)
        if err or not macula_words:
            return {
                "reference": reference,
                "language": lang,
                "macula_available": False,
                "words": words,
            }

        # Build enrichment lookups keyed by xml:id
        role_map = _build_role_map(macula_words)
        part_map = _build_participant_map(macula_words)
        sense_map = _build_sense_map(macula_words, lang)

        # Merge: match morphology words to MACULA words by list index
        for i, wdict in enumerate(words):
            if i >= len(macula_words):
                break
            mw = macula_words[i]
            xml_id = _macula_xml_id(mw)
            wdict["semantic_role"] = role_map.get(xml_id)
            wdict["participant"] = part_map.get(xml_id)
            wdict["word_sense"] = sense_map.get(xml_id, {})

        return {
            "reference": reference,
            "language": lang,
            "macula_available": True,
            "words": words,
        }
    except Exception:
        # Any MACULA failure → fall back to base only (no crash)
        return {
            "reference": reference,
            "language": lang,
            "macula_available": False,
            "words": words,
        }


def resolve_participant(
    reference: str, pronoun: str, language: str = "greek"
) -> Optional[str]:
    """Resolve who a pronoun refers to in a biblical passage using MACULA data.

    Answers questions like: *"Who does 'he' refer to in John 1:3?"*

    Uses the MACULA participant reference chain (``Ref`` attributes on
    pronouns pointing to their antecedent's xml:id).

    Args:
        reference: Bible reference, e.g. ``"John 1:3"``.
        pronoun: The pronoun to resolve, e.g. ``"he"``, ``"αὐτός"``.
        language: ``"greek"`` (default) or ``"hebrew"``.

    Returns:
        The identified referent text (e.g. ``"Ι̅Η̅C"``, ``"Jesus"``),
        or **None** if MACULA data is unavailable or the pronoun cannot
        be resolved.
    """
    reader = _get_macula_reader()
    if reader is None or not reader.available():
        return None

    lang = "greek" if language in ("greek", "el") else "hebrew"
    try:
        result = reader.resolve_participant(reference, pronoun, lang)
        if "_error" in result:
            return None
        resolved = result.get("result", "")
        # Filter out non-answer strings (MACULA returns descriptive messages
        # when a match isn't found)
        if resolved and (
            "not found" not in resolved.lower()
            and "no participant" not in resolved.lower()
        ):
            return resolved
        return None
    except Exception:
        return None
