"""MACULA data reader — syntax trees, semantic roles, participant referents.

Reads MACULA Greek and Hebrew datasets (Clear-Bible, CC-BY-4.0 / MIT).
Provides:
- Syntax trees (word-level dependencies)
- Semantic roles: Agent, Verb, Patient, etc.
- Participant referents: who pronouns and null subjects refer to
- Word senses: Louw & Nida (Greek), SDBH (Hebrew)
- Cherith glosses (Hebrew)

Data format: nested XML <Node> trees with linguistic annotations.
Greek: one XML file per NT book (e.g. 04-john.xml)
Hebrew: one XML file per chapter (e.g. 01-Gen-001.xml)
"""

from __future__ import annotations

import logging
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_DEFAULT_DATA_DIR = Path.home() / "accordance-data" / "macula"

_NOT_DOWNLOADED_MSG = (
    "MACULA data not found.\n"
    "Download it with:\n"
    "    python3 scripts/download_macula.py\n"
    "Or clone manually:\n"
    "    git clone --depth 1 https://github.com/Clear-Bible/macula-greek \\\n"
    "        ~/accordance-data/macula/macula-greek\n"
    "    git clone --depth 1 https://github.com/Clear-Bible/macula-hebrew \\\n"
    "        ~/accordance-data/macula/macula-hebrew"
)

# Book indices MACULA uses in filenames for Greek and Hebrew
# Greek NT: 01=Matthew, 02=Mark, ..., 27=Revelation
# Hebrew OT: 01=Genesis, 02=Exodus, ..., 39=Malachi (or similar)

_GREEK_BOOK_MAP: Dict[str, Tuple[int, str]] = {
    # (index, filename-stem)
    "Matt": (1, "matthew"),
    "Mark": (2, "mark"),
    "Luke": (3, "luke"),
    "John": (4, "john"),
    "Acts": (5, "acts"),
    "Rom": (6, "romans"),
    "1Cor": (7, "1corinthians"),
    "2Cor": (8, "2corinthians"),
    "Gal": (9, "galatians"),
    "Eph": (10, "ephesians"),
    "Phil": (11, "philippians"),
    "Col": (12, "colossians"),
    "1Thess": (13, "1thessalonians"),
    "2Thess": (14, "2thessalonians"),
    "1Tim": (15, "1timothy"),
    "2Tim": (16, "2timothy"),
    "Titus": (17, "titus"),
    "Phlm": (18, "philemon"),
    "Heb": (19, "hebrews"),
    "Jas": (20, "james"),
    "1Pet": (21, "1peter"),
    "2Pet": (22, "2peter"),
    "1John": (23, "1john"),
    "2John": (24, "2john"),
    "3John": (25, "3john"),
    "Jude": (26, "jude"),
    "Rev": (27, "revelation"),
}

_HEBREW_BOOK_MAP: Dict[str, Tuple[int, str]] = {
    "Gen": (1, "Gen"),
    "Exod": (2, "Exo"),
    "Lev": (3, "Lev"),
    "Num": (4, "Num"),
    "Deut": (5, "Deu"),
    "Josh": (6, "Jos"),
    "Judg": (7, "Jdg"),
    "Ruth": (8, "Rut"),
    "1Sam": (9, "1Sa"),
    "2Sam": (10, "2Sa"),
    "1Kgs": (11, "1Ki"),
    "2Kgs": (12, "2Ki"),
    "1Chr": (13, "1Ch"),
    "2Chr": (14, "2Ch"),
    "Ezra": (15, "Ezr"),
    "Neh": (16, "Neh"),
    "Esth": (17, "Est"),
    "Job": (18, "Job"),
    "Ps": (19, "Psa"),
    "Prov": (20, "Pro"),
    "Eccl": (21, "Ecc"),
    "Song": (22, "Sol"),
    "Isa": (23, "Isa"),
    "Jer": (24, "Jer"),
    "Lam": (25, "Lam"),
    "Ezek": (26, "Eze"),
    "Dan": (27, "Dan"),
    "Hos": (28, "Hos"),
    "Joel": (29, "Joe"),
    "Amos": (30, "Amo"),
    "Obad": (31, "Oba"),
    "Jonah": (32, "Jon"),
    "Mic": (33, "Mic"),
    "Nah": (34, "Nah"),
    "Hab": (35, "Hab"),
    "Zeph": (36, "Zep"),
    "Hag": (37, "Hag"),
    "Zech": (38, "Zec"),
    "Mal": (39, "Mal"),
}


# ---------------------------------------------------------------------------
# Reference parsing (lightweight — uses canon module if available)
# ---------------------------------------------------------------------------


def _parse_ref(ref: str) -> Optional[Tuple[str, int, Optional[int]]]:
    """Parse a human-readable Bible reference into (osis, chapter, verse).

    Returns None if the reference cannot be parsed.
    Handles: "John 1:1", "Genesis 1:1", "Ps 119", "Jude 4"
    """
    try:
        from .canon import parse_reference

        result = parse_reference(ref)
        if result is not None:
            return (result.book_osis, result.chapter, result.verse)
        return None
    except ImportError:
        pass

    # Fallback regex parser
    REF_RE = re.compile(
        r"(?P<book>(?:1|2|3|I+|)\s*[A-Za-z]+(?:\s+of\s+[A-Za-z]+)?)\s+"
        r"(?P<ch>\d+)(?::(?P<vs>\d+))?"
    )
    m = REF_RE.match(ref.strip())
    if not m:
        return None
    book_raw = m.group("book").strip()
    ch = int(m.group("ch"))
    vs = int(m.group("vs")) if m.group("vs") else None
    # crude OSIS mapping
    book_lower = book_raw.lower()
    for osis in _GREEK_BOOK_MAP:
        if osis.lower() in book_lower or book_lower in osis.lower():
            return (osis, ch, vs)
    for osis in _HEBREW_BOOK_MAP:
        if osis.lower() in book_lower or book_lower in osis.lower():
            return (osis, ch, vs)
    return None


# ---------------------------------------------------------------------------
# XML node-tree parser
# ---------------------------------------------------------------------------

# XML namespace for xml:id
_XML_NS = "http://www.w3.org/XML/1998/namespace"
_XML_ID_KEY = f"{{{_XML_NS}}}id"


def _normalize_attrib(attrib: Dict[str, str]) -> Dict[str, str]:
    """Normalize attribute dict: hoist namespaced xml:id to plain 'xml_id' key."""
    d = dict(attrib)
    if _XML_ID_KEY in d:
        d["xml_id"] = d.pop(_XML_ID_KEY)
    # Also handle the non-namespaced variant that some elements may use
    if "xml:id" in d:
        d["xml_id"] = d.pop("xml:id")
    return d


def _get_xml_id(word: Dict[str, Any]) -> str:
    """Get the xml:id value from a word dict, handling namespace variants."""
    return word.get("xml_id", word.get("nodeId", ""))


def _node_to_dict(elem: ET.Element) -> Dict[str, Any]:
    """Convert an XML <Node> element (leaf or branch) to a dict."""
    d: Dict[str, Any] = {
        "tag": elem.tag,
        "attributes": dict(elem.attrib),
        "children": [],
    }
    for child in elem:
        if child.tag == "Node":
            d["children"].append(_node_to_dict(child))
    return d


def _flatten_words(root: ET.Element) -> List[Dict[str, Any]]:
    """Extract all leaf <Node> elements (words) from a tree, depth-first."""
    words: List[Dict[str, Any]] = []
    _collect_leaves(root, words)
    return words


def _collect_leaves(elem: ET.Element, out: List[Dict[str, Any]]) -> None:
    children = list(elem)
    if not children:
        # Leaf node — likely a word
        out.append(_normalize_attrib(elem.attrib))
    else:
        for child in children:
            if child.tag == "Node":
                _collect_leaves(child, out)


# ---------------------------------------------------------------------------
# File discovery
# ---------------------------------------------------------------------------


def _find_greek_file(data_dir: Path, osis: str) -> Optional[Path]:
    """Find the MACULA Greek XML nodes file for a given OSIS book."""
    info = _GREEK_BOOK_MAP.get(osis)
    if not info:
        return None
    idx, stem = info
    # Format: NN-bookname.xml
    filename = f"{idx:02d}-{stem}.xml"
    # Search in Nestle1904/nodes/ and SBLGNT/nodes/
    for source in ("Nestle1904", "SBLGNT"):
        for fmt in ("nodes", "lowfat", "tei"):
            candidate = data_dir / "macula-greek" / source / fmt / filename
            if candidate.exists():
                return candidate
    # Also try without subdir prefix
    for source in ("Nestle1904", "SBLGNT"):
        for fmt in ("nodes", "lowfat", "tei"):
            candidate = data_dir / "macula-greek" / source / fmt
            if candidate.is_dir():
                # Try glob match
                matches = list(candidate.glob(f"{idx:02d}-*"))
                if matches:
                    return matches[0]
    return None


def _find_hebrew_file(data_dir: Path, osis: str, chapter: int) -> Optional[Path]:
    """Find the MACULA Hebrew XML nodes file for a given OSIS book + chapter."""
    info = _HEBREW_BOOK_MAP.get(osis)
    if not info:
        return None
    idx, abbrev = info
    # Format: NN-Abbrev-CCC.xml
    filename = f"{idx:02d}-{abbrev}-{chapter:03d}.xml"
    for fmt in ("nodes", "lowfat"):
        candidate = data_dir / "macula-hebrew" / "WLC" / fmt / filename
        if candidate.exists():
            return candidate
    return None


# ---------------------------------------------------------------------------
# XML parsing for a specific verse
# ---------------------------------------------------------------------------


def _parse_verse_from_file(
    filepath: Path, osis: str, chapter: int, verse: Optional[int]
) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]], str]:
    """Parse XML file and extract data for a specific reference.

    Returns:
        (tree_dict, words_list, message)
        If verse not found, tree_dict is None and words_list is empty.
    """
    if not filepath.exists():
        return None, [], f"File not found: {filepath}"

    try:
        tree = ET.parse(str(filepath))
    except ET.ParseError as e:
        return None, [], f"XML parse error in {filepath.name}: {e}"
    except OSError as e:
        return None, [], f"Cannot read {filepath}: {e}"

    root = tree.getroot()

    # Build a reference pattern to match Sentence ref attributes
    # MACULA uses formats like: "JUD 1:1!1-1:1!17" or "JHN 1:1!1-1:1!5"
    if verse is not None:
        ref_pattern = re.compile(
            rf"{re.escape(osis)}\s+{chapter}:{verse}[!\-]",
            re.IGNORECASE,
        )
    else:
        ref_pattern = re.compile(
            rf"{re.escape(osis)}\s+{chapter}:",
            re.IGNORECASE,
        )

    # Also try the 3-letter abbreviation patterns that MACULA uses sometimes
    # e.g., "JUD" for Jude, "JHN" for John
    alt_ref_pattern = None
    if verse is not None:
        # Build alternate pattern without relying on OSIS exactly
        alt_ref_pattern = re.compile(rf"\w+\s+{chapter}:{verse}[!\-]")

    all_words: List[Dict[str, Any]] = []
    tree_dict: Optional[Dict[str, Any]] = None

    for sentence in root.iter("Sentence"):
        ref = sentence.get("ref", "")
        if ref_pattern.search(ref) or (alt_ref_pattern and alt_ref_pattern.search(ref)):
            for trees_elem in sentence.iter("Trees"):
                for tree_elem in trees_elem.iter("Tree"):
                    if tree_dict is None:
                        tree_dict = _node_to_dict(tree_elem)
                        tree_dict["ref"] = ref
                    words = _flatten_words(tree_elem)
                    all_words.extend(words)
            if tree_dict is not None:
                # Found the verse, stop searching
                break

    if tree_dict is None:
        return (
            None,
            [],
            (
                f"Verse not found in {filepath.name}. "
                f"Checked for '{osis} {chapter}:{verse}'"
            ),
        )

    return tree_dict, all_words, "OK"


# ---------------------------------------------------------------------------
# MaculaReader
# ---------------------------------------------------------------------------


class MaculaReader:
    """Reader for MACULA Greek and Hebrew linguistic datasets.

    Usage:
        reader = MaculaReader()
        if reader.available():
            tree = reader.get_syntax_tree("John 1:1")
            roles = reader.get_semantic_roles("John 1:1")
            refs = reader.get_participant_referents("John 1:1")
            senses = reader.get_word_senses("John 1:1", language="greek")
            who = reader.resolve_participant("John 1:3", "he")
    """

    def __init__(self, data_dir: Optional[str | Path] = None):
        """Initialize the MACULA reader.

        Args:
            data_dir: Path to the MACULA data directory.
                      Defaults to ~/accordance-data/macula/.
        """
        self.data_dir = Path(data_dir) if data_dir else _DEFAULT_DATA_DIR
        self._greek_dir = self.data_dir / "macula-greek"
        self._hebrew_dir = self.data_dir / "macula-hebrew"

    # ------------------------------------------------------------------
    # Availability
    # ------------------------------------------------------------------

    def available(self) -> bool:
        """Return True if MACULA datasets exist and are readable."""
        greek_ok = (
            self._greek_dir.is_dir()
            and (self._greek_dir / "Nestle1904" / "nodes").is_dir()
        )
        hebrew_ok = (
            self._hebrew_dir.is_dir() and (self._hebrew_dir / "WLC" / "nodes").is_dir()
        )
        return greek_ok or hebrew_ok

    def greek_available(self) -> bool:
        """Return True if MACULA Greek data exists."""
        return (
            self._greek_dir.is_dir()
            and (self._greek_dir / "Nestle1904" / "nodes").is_dir()
        )

    def hebrew_available(self) -> bool:
        """Return True if MACULA Hebrew data exists."""
        return (
            self._hebrew_dir.is_dir() and (self._hebrew_dir / "WLC" / "nodes").is_dir()
        )

    def _check_downloaded(self) -> Optional[str]:
        """Return error message if data is missing, or None if OK."""
        if not self.available():
            return _NOT_DOWNLOADED_MSG
        return None

    def _check_lang(self, language: str) -> Optional[str]:
        """Return error message if language-specific data missing, or None if OK."""
        if language == "greek" and not self.greek_available():
            return f"MACULA Greek data not found at {self._greek_dir}\n\n{_NOT_DOWNLOADED_MSG}"
        if language == "hebrew" and not self.hebrew_available():
            return f"MACULA Hebrew data not found at {self._hebrew_dir}\n\n{_NOT_DOWNLOADED_MSG}"
        if language not in ("greek", "hebrew"):
            return f"Unknown language: {language!r}. Use 'greek' or 'hebrew'."
        return None

    # ------------------------------------------------------------------
    # Internal: resolve reference → file + verse data
    # ------------------------------------------------------------------

    def _resolve_and_parse(
        self,
        reference: str,
        language: str,
    ) -> Tuple[Optional[str], Optional[Dict[str, Any]], List[Dict[str, Any]]]:
        """Resolve a reference to a file, parse it, and return (error, tree, words).

        Returns:
            (error_msg_or_None, syntax_tree_dict_or_None, word_list)
        """
        err = self._check_downloaded()
        if err:
            return err, None, []

        lang_err = self._check_lang(language)
        if lang_err:
            return lang_err, None, []

        parsed = _parse_ref(reference)
        if parsed is None:
            return f"Cannot parse reference: {reference!r}", None, []

        osis, chapter, verse = parsed

        if language == "greek":
            filepath = _find_greek_file(self.data_dir, osis)
        else:
            filepath = _find_hebrew_file(self.data_dir, osis, chapter)

        if filepath is None:
            book_name = osis
            return (
                f"No MACULA {language} data file found for '{book_name}' "
                f"(osis={osis}). Check that the dataset is downloaded and "
                f"contains data for this book.",
                None,
                [],
            )

        tree_dict, words, msg = _parse_verse_from_file(filepath, osis, chapter, verse)
        if tree_dict is None:
            return msg, None, []

        return None, tree_dict, words

    # ------------------------------------------------------------------
    # Public API: Syntax trees
    # ------------------------------------------------------------------

    def get_syntax_tree(
        self, reference: str, language: str = "greek"
    ) -> Dict[str, Any]:
        """Return the syntax tree for a verse as a nested dict.

        Args:
            reference: e.g. "John 1:1"
            language: "greek" (default) or "hebrew"

        Returns:
            Dict with keys:
              - "reference": the parsed reference string
              - "language": "greek" or "hebrew"
              - "tree": nested dict representation of the XML syntax tree
              - "words": list of word dicts (leaf nodes with attributes)
            On error: dict with key "_error" containing the error message.
        """
        err, tree, words = self._resolve_and_parse(reference, language)
        if err:
            return {"_error": err}

        return {
            "reference": reference,
            "language": language,
            "tree": tree,
            "words": words,
        }

    # ------------------------------------------------------------------
    # Public API: Semantic roles
    # ------------------------------------------------------------------

    def get_semantic_roles(
        self, reference: str, language: str = "greek"
    ) -> Dict[str, Any]:
        """Extract semantic role annotations (Agent/Verb/Patient/etc.) per word.

        MACULA marks semantic roles via the 'Frame' attribute on verbs.
        Frame format: "A0:n123 A1:n456" where A0=Agent, A1=Patient, etc.
        Pronouns have 'Ref' linking to their referent's xml:id.

        Args:
            reference: e.g. "John 1:1"
            language: "greek" (default) or "hebrew"

        Returns:
            Dict with keys:
              - "reference"
              - "language"
              - "roles": list of word dicts containing role-relevant fields:
                  {text, gloss, cat, frame, subj_ref, ref, xml_id}
              - "frame_summary": list of {verb, frame, roles: {A0: ..., A1: ...}}
            On error: dict with key "_error" containing the error message.
        """
        err, tree, words = self._resolve_and_parse(reference, language)
        if err:
            return {"_error": err}

        roles: List[Dict[str, Any]] = []
        for w in words:
            frame = w.get("Frame", "")
            subj_ref = w.get("SubjRef", "")
            ref = w.get("Ref", "")
            roles.append(
                {
                    "text": w.get("Unicode", "").rstrip(",.;·"),
                    "gloss": w.get("Gloss", ""),
                    "cat": w.get("Cat", ""),
                    "xml_id": _get_xml_id(w),
                    "frame": frame,
                    "subj_ref": subj_ref,
                    "ref": ref,
                }
            )

        # Frame summary: group by verb
        frame_summary: List[Dict[str, Any]] = []
        for w in words:
            frame = w.get("Frame", "")
            if not frame:
                continue
            verb_text = w.get("Unicode", "").rstrip(",.;·")
            verb_gloss = w.get("Gloss", "")
            parsed_frame: Dict[str, str] = {}
            for part in frame.split():
                if ":" in part:
                    role, target = part.split(":", 1)
                    # Look up the word text for the target xml:id
                    target_word = _lookup_word_by_id(words, target)
                    parsed_frame[role] = target_word if target_word else target
            frame_summary.append(
                {
                    "verb": verb_text,
                    "gloss": verb_gloss,
                    "frame": frame,
                    "roles": parsed_frame,
                }
            )

        return {
            "reference": reference,
            "language": language,
            "roles": roles,
            "frame_summary": frame_summary,
        }

    # ------------------------------------------------------------------
    # Public API: Participant referents
    # ------------------------------------------------------------------

    def get_participant_referents(
        self, reference: str, language: str = "greek"
    ) -> Dict[str, Any]:
        """Return participant referent tracking — who each pronoun refers to.

        MACULA marks pronouns and implicit subjects with a 'Ref' attribute
        that points to another word's xml:id (the referent).

        Args:
            reference: e.g. "John 1:3"
            language: "greek" (default) or "hebrew"

        Returns:
            Dict with keys:
              - "reference"
              - "language"
              - "referents": list of {pronoun_text, pronoun_gloss, pronoun_id,
                                       referent_text, referent_gloss, referent_id}
            On error: dict with key "_error" containing the error message.
        """
        err, tree, words = self._resolve_and_parse(reference, language)
        if err:
            return {"_error": err}

        referents: List[Dict[str, Any]] = []
        for w in words:
            ref_target = w.get("Ref", "")
            if not ref_target:
                continue
            pronoun_text = w.get("Unicode", "").rstrip(",.;·")
            pronoun_gloss = w.get("Gloss", "")
            pronoun_id = _get_xml_id(w)
            # Resolve the referent
            referent = _lookup_word_by_id(words, ref_target)
            referents.append(
                {
                    "pronoun_text": pronoun_text,
                    "pronoun_gloss": pronoun_gloss,
                    "pronoun_id": pronoun_id,
                    "referent_text": referent if referent else ref_target,
                    "referent_id": ref_target,
                }
            )

        return {
            "reference": reference,
            "language": language,
            "referents": referents,
        }

    # ------------------------------------------------------------------
    # Public API: Word senses
    # ------------------------------------------------------------------

    def get_word_senses(
        self, reference: str, language: str = "greek"
    ) -> Dict[str, Any]:
        """Return word sense annotations — Louw & Nida (Greek) or SDBH (Hebrew).

        Greek: LexDomain + LN (Louw & Nida number)
        Hebrew: domain (SDBH) + gloss (Cherith)

        Args:
            reference: e.g. "John 1:1"
            language: "greek" (default) or "hebrew"

        Returns:
            Dict with keys:
              - "reference"
              - "language"
              - "words": list of {text, gloss, domain, lexicon_entry, cat, strongs}
            On error: dict with key "_error" containing the error message.
        """
        err, tree, words = self._resolve_and_parse(reference, language)
        if err:
            return {"_error": err}

        word_senses: List[Dict[str, Any]] = []
        for w in words:
            entry: Dict[str, Any] = {
                "text": w.get("Unicode", "").rstrip(",.;·"),
                "gloss": w.get("Gloss", ""),
                "cat": w.get("Cat", ""),
                "strongs": w.get("StrongNumber", ""),
            }
            if language == "greek":
                entry["domain"] = w.get("LexDomain", "")
                entry["lexicon_entry"] = w.get("LN", "")
                entry["lemma"] = w.get("UnicodeLemma", "")
            else:
                entry["domain"] = w.get("LexDomain", w.get("domain", ""))
                entry["lexicon_entry"] = w.get("LN", w.get("SDBH", ""))
                entry["lemma"] = w.get("UnicodeLemma", w.get("lemma", ""))
            word_senses.append(entry)

        return {
            "reference": reference,
            "language": language,
            "words": word_senses,
        }

    # ------------------------------------------------------------------
    # Public API: Resolve participant
    # ------------------------------------------------------------------

    def resolve_participant(
        self, reference: str, pronoun_phrase: str, language: str = "greek"
    ) -> Dict[str, Any]:
        """Answer: "Who does 'he' refer to in John 1:3?"

        Looks up pronoun referents in the MACULA participant annotations.

        Args:
            reference: e.g. "John 1:3"
            pronoun_phrase: e.g. "he", "him", "αὐτός", "ὑμῖν"
            language: "greek" (default) or "hebrew"

        Returns:
            Dict with keys:
              - "query": the pronoun phrase queried
              - "reference"
              - "result": the referent text (or "not found" message)
              - "candidates": list of matching pronoun→referent pairs
            On error: dict with key "_error" containing the error message.
        """
        result = self.get_participant_referents(reference, language)
        if "_error" in result:
            return result

        candidates: List[Dict[str, Any]] = []
        pronoun_lower = pronoun_phrase.lower().strip()

        for ref in result.get("referents", []):
            p_text = ref["pronoun_text"].lower().rstrip(",.;·")
            p_gloss = ref["pronoun_gloss"].lower().rstrip(",.;·")
            if pronoun_lower in p_text or pronoun_lower in p_gloss:
                candidates.append(ref)

        if candidates:
            best = candidates[0]
            return {
                "query": pronoun_phrase,
                "reference": reference,
                "language": language,
                "result": best["referent_text"],
                "candidates": candidates,
            }

        # Try fuzzy: search for any pronoun with a referent
        all_with_refs = [r for r in result.get("referents", []) if r["referent_text"]]
        if all_with_refs:
            return {
                "query": pronoun_phrase,
                "reference": reference,
                "language": language,
                "result": (
                    f"Exact pronoun {pronoun_phrase!r} not found in this verse. "
                    f"Available referents: "
                    + ", ".join(
                        f"{r['pronoun_text']}→{r['referent_text']}"
                        for r in all_with_refs
                    )
                ),
                "candidates": all_with_refs,
            }

        return {
            "query": pronoun_phrase,
            "reference": reference,
            "language": language,
            "result": (
                f"No participant referent data found for {pronoun_phrase!r} "
                f"in {reference}."
            ),
            "candidates": [],
        }


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


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