"""
DOCX Document Extractor
=======================

Extracts text content, metadata, and structure from Microsoft Word .docx files
(Office Open XML format, Word 2007 and later).

This module uses direct XML parsing of the docx ZIP archive structure for all
content extraction, without requiring the python-docx library.

File Format Background
----------------------
The .docx format is a ZIP archive containing XML files following the Office
Open XML (OOXML) standard. Key components:

    word/document.xml: Main document body (paragraphs, tables)
    word/styles.xml: Style definitions
    word/footnotes.xml: Footnote content
    word/endnotes.xml: Endnote content
    word/comments.xml: Comment/annotation content
    word/header1.xml, footer1.xml: Header/footer content
    word/media/: Embedded images
    word/_rels/document.xml.rels: Relationships (images, hyperlinks)
    docProps/core.xml: Metadata (title, author, dates)

XML Namespaces:
    - w: http://schemas.openxmlformats.org/wordprocessingml/2006/main
    - m: http://schemas.openxmlformats.org/officeDocument/2006/math
    - mc: http://schemas.openxmlformats.org/markup-compatibility/2006
    - r: http://schemas.openxmlformats.org/officeDocument/2006/relationships
    - a: http://schemas.openxmlformats.org/drawingml/2006/main
    - cp: http://schemas.openxmlformats.org/package/2006/metadata/core-properties
    - dc: http://purl.org/dc/elements/1.1/
    - dcterms: http://purl.org/dc/terms/

Math Formula Handling
---------------------
Word documents store mathematical formulas in OMML (Office Math Markup Language).
This module converts OMML to LaTeX-like notation for text representation.

Supported OMML elements:
    - m:f (fraction) -> \\frac{num}{den}
    - m:sSup/m:sSub (super/subscript) -> base^{sup} / base_{sub}
    - m:rad (radical/root) -> \\sqrt{content}
    - m:nary (n-ary operators) -> \\sum, \\int, etc.
    - m:d (delimiter) -> parentheses, brackets
    - m:m (matrix) -> \\begin{matrix}...\\end{matrix}
    - m:func (functions) -> \\sin, \\cos, etc.
    - m:bar/m:acc (overline/accent) -> \\overline, \\hat, etc.

The OMML-to-LaTeX converter also handles:
    - Greek letters (α -> \\alpha, etc.)
    - Math symbols (∞ -> \\infty, etc.)
    - Malformed bracket placement in roots

AlternateContent Handling
-------------------------
Word uses mc:AlternateContent elements to provide fallback representations
for features like equations. This extractor processes only mc:Choice content
and skips mc:Fallback to avoid duplicate text extraction.

Extracted Content
-----------------
The extractor retrieves:
    - Main body text (paragraphs and tables in order)
    - Headers and footers (default, first page, even page)
    - Footnotes and endnotes
    - Comments with author and date
    - Images with metadata
    - Hyperlinks with URLs
    - Formulas as LaTeX
    - Section properties (page layout)
    - Style names used

Two text outputs are provided:
    - full_text: Complete text including formulas as LaTeX
    - base_full_text: Text without formula representations

Known Limitations
-----------------
- Embedded OLE objects are not extracted
- Complex SmartArt text may be incomplete
- Drawing canvas text may not extract properly
- Tracked changes are not separately reported
- Password-protected files are not supported
- Very large documents may use significant memory

Usage
-----
    >>> import io
    >>> from sharepoint2text.extractors.ms_modern.docx_extractor import read_docx
    >>>
    >>> with open("document.docx", "rb") as f:
    ...     for doc in read_docx(io.BytesIO(f.read()), path="document.docx"):
    ...         print(f"Title: {doc.metadata.title}")
    ...         print(f"Author: {doc.metadata.author}")
    ...         print(f"Paragraphs: {len(doc.paragraphs)}")
    ...         print(doc.full_text[:500])

See Also
--------
- OOXML WordprocessingML: https://docs.microsoft.com/en-us/openspecs/office_standards/
- doc_extractor: For legacy .doc format

Maintenance Notes
-----------------
- The OMML-to-LaTeX converter handles common cases but may need extension
- Direct XML parsing is used for all content extraction
- AlternateContent handling prevents duplicate formula text
- Greek letter and symbol mapping can be extended as needed
"""

import io
import logging
import zipfile
from typing import Any, Generator
from xml.etree import ElementTree as ET

from sharepoint2text.extractors.data_types import (
    DocxComment,
    DocxContent,
    DocxFormula,
    DocxHeaderFooter,
    DocxHyperlink,
    DocxImage,
    DocxMetadata,
    DocxNote,
    DocxParagraph,
    DocxRun,
    DocxSection,
)

logger = logging.getLogger(__name__)

# XML Namespaces used in OOXML documents
NAMESPACES = {
    "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
    "m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
    "mc": "http://schemas.openxmlformats.org/markup-compatibility/2006",
    "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
    "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
    "wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
    "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
    "dc": "http://purl.org/dc/elements/1.1/",
    "dcterms": "http://purl.org/dc/terms/",
    "rel": "http://schemas.openxmlformats.org/package/2006/relationships",
    "ct": "http://schemas.openxmlformats.org/package/2006/content-types",
}

# Namespace prefixes for element access
W_NS = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
M_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/math}"
MC_NS = "{http://schemas.openxmlformats.org/markup-compatibility/2006}"
R_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
A_NS = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
WP_NS = "{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}"
CP_NS = "{http://schemas.openxmlformats.org/package/2006/metadata/core-properties}"
DC_NS = "{http://purl.org/dc/elements/1.1/}"
DCTERMS_NS = "{http://purl.org/dc/terms/}"
REL_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"

# EMU (English Metric Units) conversion: 914400 EMU = 1 inch
EMU_PER_INCH = 914400
# Twips conversion: 1440 twips = 1 inch
TWIPS_PER_INCH = 1440


class _DocxFullTextExtractor:
    """
    Extracts a complete text representation from a DOCX file.

    This class handles the complex task of extracting text from Word documents
    while preserving the order of paragraphs, tables, and mathematical formulas.
    It processes the raw XML structure of the document.

    Key Features:
        - Preserves document element order (paragraphs, tables, formulas)
        - Converts OMML math formulas to LaTeX notation
        - Handles AlternateContent elements correctly (avoids duplicates)
        - Supports both inline ($...$) and display ($$...$$) math

    Class Attributes:
        GREEK_TO_LATEX: Mapping of Greek letters and math symbols to LaTeX

    Usage:
        >>> text = _DocxFullTextExtractor.extract_full_text(file_like)
        >>> latex = _DocxFullTextExtractor.omml_to_latex(omath_element)
    """

    # Greek letter and symbol mapping for LaTeX conversion
    # Lowercase and uppercase Greek, plus common mathematical symbols
    GREEK_TO_LATEX = {
        # Lowercase Greek
        "α": "\\alpha",
        "β": "\\beta",
        "γ": "\\gamma",
        "δ": "\\delta",
        "ε": "\\epsilon",
        "ζ": "\\zeta",
        "η": "\\eta",
        "θ": "\\theta",
        "ι": "\\iota",
        "κ": "\\kappa",
        "λ": "\\lambda",
        "μ": "\\mu",
        "ν": "\\nu",
        "ξ": "\\xi",
        "ο": "o",  # omicron is just 'o' in LaTeX
        "π": "\\pi",
        "ρ": "\\rho",
        "σ": "\\sigma",
        "ς": "\\varsigma",
        "τ": "\\tau",
        "υ": "\\upsilon",
        "φ": "\\phi",
        "χ": "\\chi",
        "ψ": "\\psi",
        "ω": "\\omega",
        # Uppercase Greek
        "Α": "A",
        "Β": "B",
        "Γ": "\\Gamma",
        "Δ": "\\Delta",
        "Ε": "E",
        "Ζ": "Z",
        "Η": "H",
        "Θ": "\\Theta",
        "Ι": "I",
        "Κ": "K",
        "Λ": "\\Lambda",
        "Μ": "M",
        "Ν": "N",
        "Ξ": "\\Xi",
        "Ο": "O",
        "Π": "\\Pi",
        "Ρ": "P",
        "Σ": "\\Sigma",
        "Τ": "T",
        "Υ": "\\Upsilon",
        "Φ": "\\Phi",
        "Χ": "X",
        "Ψ": "\\Psi",
        "Ω": "\\Omega",
        # Common math symbols
        "∞": "\\infty",
        "∂": "\\partial",
        "∇": "\\nabla",
        "±": "\\pm",
        "∓": "\\mp",
        "×": "\\times",
        "÷": "\\div",
        "·": "\\cdot",
        "≤": "\\leq",
        "≥": "\\geq",
        "≠": "\\neq",
        "≈": "\\approx",
        "≡": "\\equiv",
        "∈": "\\in",
        "∉": "\\notin",
        "⊂": "\\subset",
        "⊃": "\\supset",
        "⊆": "\\subseteq",
        "⊇": "\\supseteq",
        "∪": "\\cup",
        "∩": "\\cap",
        "∧": "\\land",
        "∨": "\\lor",
        "¬": "\\neg",
        "→": "\\rightarrow",
        "←": "\\leftarrow",
        "↔": "\\leftrightarrow",
        "⇒": "\\Rightarrow",
        "⇐": "\\Leftarrow",
        "⇔": "\\Leftrightarrow",
        "∀": "\\forall",
        "∃": "\\exists",
        "∅": "\\emptyset",
        "ℕ": "\\mathbb{N}",
        "ℤ": "\\mathbb{Z}",
        "ℚ": "\\mathbb{Q}",
        "ℝ": "\\mathbb{R}",
        "ℂ": "\\mathbb{C}",
    }

    @classmethod
    def _convert_greek_and_symbols(cls, text: str) -> str:
        """
        Convert Greek letters and math symbols to LaTeX equivalents.

        Args:
            text: Input string potentially containing Unicode Greek/math chars.

        Returns:
            String with Greek letters and symbols replaced by LaTeX commands.

        Example:
            >>> _DocxFullTextExtractor._convert_greek_and_symbols("αβγ")
            '\\alpha\\beta\\gamma'
        """
        result = []
        for char in text:
            if char in cls.GREEK_TO_LATEX:
                result.append(cls.GREEK_TO_LATEX[char])
            else:
                result.append(char)
        return "".join(result)

    @classmethod
    def omml_to_latex(cls, omath_element) -> str:
        """
        Convert an OMML (Office Math Markup Language) element to LaTeX notation.

        This method recursively processes the OMML XML structure and produces
        a LaTeX string representation suitable for rendering or display.

        Args:
            omath_element: An ElementTree Element representing an m:oMath or m:oMathPara
                element from the document XML.

        Returns:
            LaTeX string representation of the mathematical expression.

        Supported OMML Elements:
            - m:f -> \\frac{numerator}{denominator}
            - m:sSup -> base^{superscript}
            - m:sSub -> base_{subscript}
            - m:sSubSup -> base_{sub}^{sup}
            - m:rad -> \\sqrt{content} or \\sqrt[n]{content}
            - m:nary -> \\sum, \\int, \\prod with limits
            - m:d -> (content) or other delimiters
            - m:m -> \\begin{matrix}...\\end{matrix}
            - m:func -> \\sin, \\cos, etc.
            - m:bar -> \\overline{content}
            - m:acc -> \\hat, \\tilde, etc.

        Malformed Input Handling:
            Some Word-generated OMML has malformed sqrt elements where the
            radical contains only an opening bracket with the content following.
            This method detects this pattern and consumes content until the
            matching closing bracket is found.

        Implementation Notes:
            - Uses recursive element processing
            - Skips property elements (rPr, fPr, etc.)
            - Converts Greek letters via _convert_greek_and_symbols
            - Tracks pending sqrt closures for malformed input
        """
        parts = []
        pending_sqrt_close = None  # Bracket needed to close current sqrt

        # Property elements to skip
        skip_tags = {
            "rPr",
            "fPr",
            "radPr",
            "ctrlPr",
            "oMathParaPr",
            "degHide",
            "type",
            "rFonts",
            "i",
            "color",
            "sz",
            "szCs",
            "jc",
            "solidFill",
            "srgbClr",
            "latin",
        }

        def process_element(elem) -> str:
            """Recursively process an element and return its LaTeX representation."""
            nonlocal pending_sqrt_close

            if elem is None:
                return ""

            tag = elem.tag.split("}")[-1]

            # Skip property elements
            if tag in skip_tags:
                return ""

            # Text content (both w:t and m:t)
            if tag == "t":
                text = elem.text or ""
                converted = cls._convert_greek_and_symbols(text)

                # Handle malformed sqrt: if we're waiting for a closing bracket
                if pending_sqrt_close and pending_sqrt_close in converted:
                    idx = converted.index(pending_sqrt_close)
                    inside = converted[:idx]  # Content inside sqrt
                    outside = converted[idx + 1 :]  # Content after closing bracket
                    pending_sqrt_close = None
                    return inside + "}" + outside

                return converted

            # Fraction: m:f contains m:num (numerator) and m:den (denominator)
            if tag == "f":
                num = elem.find(f"{M_NS}num")
                den = elem.find(f"{M_NS}den")
                num_text = process_element(num)
                den_text = process_element(den)
                return f"\\frac{{{num_text}}}{{{den_text}}}"

            # Superscript: m:sSup contains m:e (base) and m:sup (superscript)
            if tag == "sSup":
                base = elem.find(f"{M_NS}e")
                sup = elem.find(f"{M_NS}sup")
                base_text = process_element(base)
                sup_text = process_element(sup)
                return f"{base_text}^{{{sup_text}}}"

            # Subscript: m:sSub contains m:e (base) and m:sub (subscript)
            if tag == "sSub":
                base = elem.find(f"{M_NS}e")
                sub = elem.find(f"{M_NS}sub")
                base_text = process_element(base)
                sub_text = process_element(sub)
                return f"{base_text}_{{{sub_text}}}"

            # Sub-superscript: m:sSubSup contains m:e, m:sub, and m:sup
            if tag == "sSubSup":
                base = elem.find(f"{M_NS}e")
                sub = elem.find(f"{M_NS}sub")
                sup = elem.find(f"{M_NS}sup")
                base_text = process_element(base)
                sub_text = process_element(sub)
                sup_text = process_element(sup)
                return f"{base_text}_{{{sub_text}}}^{{{sup_text}}}"

            # Square root: m:rad contains m:deg (degree, optional) and m:e (content)
            if tag == "rad":
                deg = elem.find(f"{M_NS}deg")
                content = elem.find(f"{M_NS}e")
                content_text = process_element(content)
                deg_text = process_element(deg).strip()

                # Handle malformed: lone opening bracket inside sqrt
                # Some OMML has sqrt containing just "(" with content after
                if content_text.strip() in ("(", "[", "{"):
                    bracket_map = {"(": ")", "[": "]", "{": "}"}
                    pending_sqrt_close = bracket_map.get(content_text.strip(), ")")
                    if deg_text:
                        return f"\\sqrt[{deg_text}]{{"
                    else:
                        return "\\sqrt{"
                else:
                    if deg_text:
                        return f"\\sqrt[{deg_text}]{{{content_text}}}"
                    else:
                        return f"\\sqrt{{{content_text}}}"

            # N-ary (sum, product, integral): m:nary
            if tag == "nary":
                chr_elem = elem.find(f".//{M_NS}chr")
                op = chr_elem.get(f"{M_NS}val") if chr_elem is not None else "∑"

                sub = elem.find(f"{M_NS}sub")
                sup = elem.find(f"{M_NS}sup")
                content = elem.find(f"{M_NS}e")

                op_map = {
                    "∑": "\\sum",
                    "∏": "\\prod",
                    "∫": "\\int",
                    "∬": "\\iint",
                    "∭": "\\iiint",
                }
                latex_op = op_map.get(op, cls._convert_greek_and_symbols(op))

                sub_text = process_element(sub)
                sup_text = process_element(sup)
                content_text = process_element(content)

                result = latex_op
                if sub_text.strip():
                    result += f"_{{{sub_text}}}"
                if sup_text.strip():
                    result += f"^{{{sup_text}}}"
                result += f" {content_text}"
                return result

            # Delimiter (parentheses, brackets): m:d
            if tag == "d":
                beg_chr = elem.find(f".//{M_NS}begChr")
                end_chr = elem.find(f".//{M_NS}endChr")
                left = beg_chr.get(f"{M_NS}val") if beg_chr is not None else "("
                right = end_chr.get(f"{M_NS}val") if end_chr is not None else ")"

                e_elements = elem.findall(f"{M_NS}e")
                content_parts = [process_element(e) for e in e_elements]
                content_text = ", ".join(content_parts)
                return f"{left}{content_text}{right}"

            # Matrix: m:m contains m:mr (rows) which contain m:e (elements)
            if tag == "m" and elem.find(f"{M_NS}mr") is not None:
                rows = []
                for mr in elem.findall(f"{M_NS}mr"):
                    cells = [process_element(e) for e in mr.findall(f"{M_NS}e")]
                    rows.append(" & ".join(cells))
                return "\\begin{matrix}" + " \\\\ ".join(rows) + "\\end{matrix}"

            # Function: m:func contains m:fName and m:e
            if tag == "func":
                fname = elem.find(f"{M_NS}fName")
                content = elem.find(f"{M_NS}e")
                fname_text = process_element(fname)
                content_text = process_element(content)
                func_map = {
                    "sin": "\\sin",
                    "cos": "\\cos",
                    "tan": "\\tan",
                    "log": "\\log",
                    "ln": "\\ln",
                    "lim": "\\lim",
                    "exp": "\\exp",
                    "max": "\\max",
                    "min": "\\min",
                }
                latex_fname = func_map.get(fname_text.strip(), fname_text)
                return f"{latex_fname}{{{content_text}}}"

            # Bar/overline: m:bar
            if tag == "bar":
                content = elem.find(f"{M_NS}e")
                content_text = process_element(content)
                return f"\\overline{{{content_text}}}"

            # Accent (hat, tilde, etc.): m:acc
            if tag == "acc":
                chr_elem = elem.find(f".//{M_NS}chr")
                accent = chr_elem.get(f"{M_NS}val") if chr_elem is not None else "^"
                content = elem.find(f"{M_NS}e")
                content_text = process_element(content)

                accent_map = {
                    "̂": "\\hat",
                    "̃": "\\tilde",
                    "̄": "\\bar",
                    "⃗": "\\vec",
                    "̇": "\\dot",
                }
                latex_accent = accent_map.get(accent, "\\hat")
                return f"{latex_accent}{{{content_text}}}"

            # Default: recurse into children and concatenate results
            result = []
            for child in elem:
                child_result = process_element(child)
                if child_result:
                    result.append(child_result)
            return "".join(result)

        # Process all children of the omath element
        for child in omath_element:
            child_result = process_element(child)
            if child_result:
                parts.append(child_result)

        # If sqrt was never closed (no matching bracket found), close it now
        if pending_sqrt_close:
            parts.append("}")

        return "".join(parts)

    @classmethod
    def extract_full_text_from_body(
        cls, body: ET.Element | None, include_formulas: bool = True
    ) -> str:
        """
        Extract the complete text content from a pre-parsed document body.

        Combines all text from paragraphs, tables, and equations into a single
        string, preserving the document order.

        Args:
            body: Pre-parsed document body element (from cached context).
            include_formulas: Whether to include LaTeX formula representations
                in the output. If True, inline formulas are wrapped in $...$
                and display formulas in $$...$$. Default is True.

        Returns:
            Complete document text as a single string with newlines between
            paragraphs and table cells.
        """
        logger.debug("Extracting document full text")

        if body is None:
            return ""

        all_text = []

        def process_element(elem, parts: list):
            """Recursively process element, handling AlternateContent properly.

            Only processes mc:Choice content and skips mc:Fallback to avoid
            extracting duplicate content from fallback representations.
            """
            tag = elem.tag.split("}")[-1]

            # Handle AlternateContent - only use Choice, skip Fallback
            if tag == "AlternateContent":
                choice = elem.find(f"{MC_NS}Choice")
                if choice is not None:
                    for child in choice:
                        process_element(child, parts)
                return

            # Skip Fallback elements entirely to avoid duplicate content
            if tag == "Fallback":
                return

            # Regular run of text
            if tag == "r":
                for child in elem:
                    child_tag = child.tag.split("}")[-1]
                    if child_tag == "t":
                        if child.text:
                            parts.append(child.text)
                    elif child_tag == "AlternateContent":
                        process_element(child, parts)
                return

            # Inline equation
            if tag == "oMath":
                if include_formulas:
                    latex = cls.omml_to_latex(elem)
                    if latex.strip():
                        parts.append(f"${latex}$")
                return

            # Display equation
            if tag == "oMathPara":
                if include_formulas:
                    omath = elem.find(f"{M_NS}oMath")
                    if omath is not None:
                        latex = cls.omml_to_latex(omath)
                        if latex.strip():
                            parts.append(f"$${latex}$$")
                return

            # Recurse into other elements
            for child in elem:
                process_element(child, parts)

        def extract_paragraph_content(p_element) -> str:
            """Extract text from paragraph including inline and display equations."""
            parts = []
            for child in p_element:
                process_element(child, parts)
            return "".join(parts)

        def extract_table_text(tbl_element) -> list[str]:
            """Extract text from table in row order."""
            texts = []
            for row in tbl_element.iter(f"{W_NS}tr"):
                for cell in row.iter(f"{W_NS}tc"):
                    cell_parts = []
                    for p in cell.iter(f"{W_NS}p"):
                        text = extract_paragraph_content(p)
                        if text.strip():
                            cell_parts.append(text)
                    if cell_parts:
                        texts.append(" ".join(cell_parts))
            return texts

        # Iterate through body elements in document order
        for element in body:
            tag = element.tag.split("}")[-1]

            if tag == "p":  # Paragraph (may contain oMathPara)
                text = extract_paragraph_content(element)
                if text.strip():
                    all_text.append(text)

            elif tag == "tbl":  # Table
                table_texts = extract_table_text(element)
                all_text.extend(table_texts)

        return "\n".join(all_text)


class _DocxContext:
    """
    Cached context for DOCX extraction.

    Opens the ZIP file once and caches all parsed XML documents and
    extracted data that is reused across multiple extraction functions.
    This avoids repeatedly opening the ZIP and parsing the same XML files.
    """

    def __init__(self, file_like: io.BytesIO):
        self.file_like = file_like
        file_like.seek(0)

        # Cache for parsed XML roots
        self._document_root: ET.Element | None = None
        self._core_root: ET.Element | None = None
        self._styles_root: ET.Element | None = None
        self._footnotes_root: ET.Element | None = None
        self._endnotes_root: ET.Element | None = None
        self._comments_root: ET.Element | None = None
        self._rels_root: ET.Element | None = None

        # Cache for extracted data
        self._relationships: dict[str, dict] | None = None
        self._styles: dict[str, str] | None = None
        self._namelist: set[str] | None = None

        # Cache for header/footer roots (keyed by path)
        self._header_footer_roots: dict[str, ET.Element] = {}

        # Open ZIP once and read all needed files
        with zipfile.ZipFile(file_like, "r") as z:
            self._namelist = set(z.namelist())
            self._load_xml_files(z)

    def _load_xml_files(self, z: zipfile.ZipFile) -> None:
        """Load and parse all XML files from the ZIP at once."""
        # Main document
        if "word/document.xml" in self._namelist:
            with z.open("word/document.xml") as f:
                self._document_root = ET.parse(f).getroot()

        # Core properties (metadata)
        if "docProps/core.xml" in self._namelist:
            with z.open("docProps/core.xml") as f:
                self._core_root = ET.parse(f).getroot()

        # Styles
        if "word/styles.xml" in self._namelist:
            with z.open("word/styles.xml") as f:
                self._styles_root = ET.parse(f).getroot()

        # Footnotes
        if "word/footnotes.xml" in self._namelist:
            with z.open("word/footnotes.xml") as f:
                self._footnotes_root = ET.parse(f).getroot()

        # Endnotes
        if "word/endnotes.xml" in self._namelist:
            with z.open("word/endnotes.xml") as f:
                self._endnotes_root = ET.parse(f).getroot()

        # Comments
        if "word/comments.xml" in self._namelist:
            with z.open("word/comments.xml") as f:
                self._comments_root = ET.parse(f).getroot()

        # Relationships
        rels_path = "word/_rels/document.xml.rels"
        if rels_path in self._namelist:
            with z.open(rels_path) as f:
                self._rels_root = ET.parse(f).getroot()

        # Pre-load header and footer files
        self._relationships = self._parse_relationships()
        for rel_id, rel_info in self._relationships.items():
            rel_type = rel_info.get("type", "")
            target = rel_info.get("target", "")
            if "header" in rel_type.lower() or "footer" in rel_type.lower():
                hf_path = "word/" + target
                if hf_path in self._namelist:
                    with z.open(hf_path) as f:
                        self._header_footer_roots[hf_path] = ET.parse(f).getroot()

    def _parse_relationships(self) -> dict[str, dict]:
        """Parse relationships from cached rels root."""
        relationships = {}
        if self._rels_root is None:
            return relationships

        for rel in self._rels_root.findall(f".//{REL_NS}Relationship"):
            rel_id = rel.get("Id") or ""
            rel_type = rel.get("Type") or ""
            rel_target = rel.get("Target") or ""
            target_mode = rel.get("TargetMode") or ""
            relationships[rel_id] = {
                "type": rel_type,
                "target": rel_target,
                "target_mode": target_mode,
            }
        return relationships

    @property
    def document_body(self) -> ET.Element | None:
        """Get the document body element."""
        if self._document_root is None:
            return None
        return self._document_root.find(f"{W_NS}body")

    @property
    def relationships(self) -> dict[str, dict]:
        """Get cached relationships."""
        if self._relationships is None:
            self._relationships = self._parse_relationships()
        return self._relationships

    @property
    def styles(self) -> dict[str, str]:
        """Get cached style map (style_id -> style_name)."""
        if self._styles is None:
            self._styles = {}
            if self._styles_root is not None:
                for style in self._styles_root.findall(f".//{W_NS}style"):
                    style_id = style.get(f"{W_NS}styleId") or ""
                    name_elem = style.find(f"{W_NS}name")
                    style_name = (
                        name_elem.get(f"{W_NS}val") if name_elem is not None else ""
                    )
                    if style_id:
                        self._styles[style_id] = style_name or style_id
        return self._styles

    def get_image_data(self, image_path: str) -> bytes | None:
        """Read image data from the ZIP file."""
        if image_path not in self._namelist:
            return None
        self.file_like.seek(0)
        with zipfile.ZipFile(self.file_like, "r") as z:
            with z.open(image_path) as img_file:
                return img_file.read()


def _extract_metadata_from_context(ctx: _DocxContext) -> DocxMetadata:
    """
    Extract document metadata from cached core.xml root.

    Args:
        ctx: DocxContext with cached XML roots.

    Returns:
        DocxMetadata object with title, author, dates, revision, etc.
    """
    logger.debug("Extracting metadata")
    metadata = DocxMetadata()

    root = ctx._core_root
    if root is None:
        return metadata

    # Extract metadata fields
    title_elem = root.find(f"{DC_NS}title")
    if title_elem is not None and title_elem.text:
        metadata.title = title_elem.text

    creator_elem = root.find(f"{DC_NS}creator")
    if creator_elem is not None and creator_elem.text:
        metadata.author = creator_elem.text

    subject_elem = root.find(f"{DC_NS}subject")
    if subject_elem is not None and subject_elem.text:
        metadata.subject = subject_elem.text

    # Keywords - may be in cp:keywords or dc:subject
    keywords_elem = root.find(f"{CP_NS}keywords")
    if keywords_elem is not None and keywords_elem.text:
        metadata.keywords = keywords_elem.text

    category_elem = root.find(f"{CP_NS}category")
    if category_elem is not None and category_elem.text:
        metadata.category = category_elem.text

    description_elem = root.find(f"{DC_NS}description")
    if description_elem is not None and description_elem.text:
        metadata.comments = description_elem.text

    created_elem = root.find(f"{DCTERMS_NS}created")
    if created_elem is not None and created_elem.text:
        metadata.created = created_elem.text

    modified_elem = root.find(f"{DCTERMS_NS}modified")
    if modified_elem is not None and modified_elem.text:
        metadata.modified = modified_elem.text

    last_modified_by_elem = root.find(f"{CP_NS}lastModifiedBy")
    if last_modified_by_elem is not None and last_modified_by_elem.text:
        metadata.last_modified_by = last_modified_by_elem.text

    revision_elem = root.find(f"{CP_NS}revision")
    if revision_elem is not None and revision_elem.text:
        try:
            metadata.revision = int(revision_elem.text)
        except ValueError:
            pass

    return metadata


def _extract_footnotes_from_context(ctx: _DocxContext) -> list[DocxNote]:
    """
    Extract footnotes from cached footnotes.xml root.

    Args:
        ctx: DocxContext with cached XML roots.

    Returns:
        List of DocxNote objects with id and text fields.
        Separator (-1) and continuation (0) footnotes are filtered out.
    """
    logger.debug("Extracting footnotes")
    footnotes = []

    root = ctx._footnotes_root
    if root is None:
        return footnotes

    for fn in root.findall(f".//{W_NS}footnote"):
        fn_id = fn.get(f"{W_NS}id") or ""
        if fn_id not in ["-1", "0"]:  # Skip separator and continuation footnotes
            text_parts = []
            for t in fn.findall(f".//{W_NS}t"):
                if t.text:
                    text_parts.append(t.text)
            footnotes.append(DocxNote(id=fn_id, text="".join(text_parts)))

    return footnotes


def _extract_comments_from_context(ctx: _DocxContext) -> list[DocxComment]:
    """
    Extract comments/annotations from cached comments.xml root.

    Args:
        ctx: DocxContext with cached XML roots.

    Returns:
        List of DocxComment objects with id, author, date, and text fields.
    """
    logger.debug("Extracting comments")
    comments = []

    root = ctx._comments_root
    if root is None:
        return comments

    for comment in root.findall(f".//{W_NS}comment"):
        text_parts = []
        for t in comment.findall(f".//{W_NS}t"):
            if t.text:
                text_parts.append(t.text)
        comments.append(
            DocxComment(
                id=comment.get(f"{W_NS}id") or "",
                author=comment.get(f"{W_NS}author") or "",
                date=comment.get(f"{W_NS}date") or "",
                text="".join(text_parts),
            )
        )

    return comments


def _extract_endnotes_from_context(ctx: _DocxContext) -> list[DocxNote]:
    """
    Extract endnotes from cached endnotes.xml root.

    Args:
        ctx: DocxContext with cached XML roots.

    Returns:
        List of DocxNote objects with id and text fields.
        Separator (-1) and continuation (0) endnotes are filtered out.
    """
    logger.debug("Extracting endnotes")
    endnotes = []

    root = ctx._endnotes_root
    if root is None:
        return endnotes

    for en in root.findall(f".//{W_NS}endnote"):
        en_id = en.get(f"{W_NS}id") or ""
        if en_id not in ["-1", "0"]:  # Skip separator and continuation endnotes
            text_parts = []
            for t in en.findall(f".//{W_NS}t"):
                if t.text:
                    text_parts.append(t.text)
            endnotes.append(DocxNote(id=en_id, text="".join(text_parts)))

    return endnotes


def _extract_sections_from_context(ctx: _DocxContext) -> list[DocxSection]:
    """
    Extract section properties (page layout) from cached document body.

    Args:
        ctx: DocxContext with cached XML roots.

    Returns:
        List of DocxSection objects with page dimensions and margins in inches.
    """
    logger.debug("Extracting sections")
    sections = []

    body = ctx.document_body
    if body is None:
        return sections

    # Find all sectPr elements (in paragraphs and at end of body)
    sect_pr_elements = []

    # Sections in paragraphs
    for p in body.findall(f".//{W_NS}p"):
        ppr = p.find(f"{W_NS}pPr")
        if ppr is not None:
            sect_pr = ppr.find(f"{W_NS}sectPr")
            if sect_pr is not None:
                sect_pr_elements.append(sect_pr)

    # Final section at end of body
    final_sect_pr = body.find(f"{W_NS}sectPr")
    if final_sect_pr is not None:
        sect_pr_elements.append(final_sect_pr)

    for sect_pr in sect_pr_elements:
        section = DocxSection()

        # Page size
        pg_sz = sect_pr.find(f"{W_NS}pgSz")
        if pg_sz is not None:
            w_val = pg_sz.get(f"{W_NS}w")
            h_val = pg_sz.get(f"{W_NS}h")
            orient = pg_sz.get(f"{W_NS}orient")

            if w_val:
                try:
                    section.page_width_inches = int(w_val) / TWIPS_PER_INCH
                except ValueError:
                    pass
            if h_val:
                try:
                    section.page_height_inches = int(h_val) / TWIPS_PER_INCH
                except ValueError:
                    pass
            # Only set orientation for non-default (landscape)
            # Portrait is the default and should remain as None
            if orient and orient != "portrait":
                section.orientation = orient

        # Page margins
        pg_mar = sect_pr.find(f"{W_NS}pgMar")
        if pg_mar is not None:
            left = pg_mar.get(f"{W_NS}left")
            right = pg_mar.get(f"{W_NS}right")
            top = pg_mar.get(f"{W_NS}top")
            bottom = pg_mar.get(f"{W_NS}bottom")

            if left:
                try:
                    section.left_margin_inches = int(left) / TWIPS_PER_INCH
                except ValueError:
                    pass
            if right:
                try:
                    section.right_margin_inches = int(right) / TWIPS_PER_INCH
                except ValueError:
                    pass
            if top:
                try:
                    section.top_margin_inches = int(top) / TWIPS_PER_INCH
                except ValueError:
                    pass
            if bottom:
                try:
                    section.bottom_margin_inches = int(bottom) / TWIPS_PER_INCH
                except ValueError:
                    pass

        sections.append(section)

    return sections


def _extract_header_footers_from_context(
    ctx: _DocxContext,
) -> tuple[list[DocxHeaderFooter], list[DocxHeaderFooter]]:
    """
    Extract headers and footers from cached header/footer XML roots.

    Args:
        ctx: DocxContext with cached XML roots.

    Returns:
        Tuple of (headers_list, footers_list) where each list contains
        DocxHeaderFooter objects with type and text fields.
    """
    logger.debug("Extracting header/footer")
    headers = []
    footers = []

    rels = ctx.relationships

    # Find header and footer files
    header_files = []
    footer_files = []

    for rel_id, rel_info in rels.items():
        rel_type = rel_info.get("type", "")
        target = rel_info.get("target", "")

        if "header" in rel_type.lower():
            header_files.append(("word/" + target, rel_type))
        elif "footer" in rel_type.lower():
            footer_files.append(("word/" + target, rel_type))

    # Extract text from header files
    for header_path, rel_type in header_files:
        root = ctx._header_footer_roots.get(header_path)
        if root is not None:
            text_parts = []
            for t in root.findall(f".//{W_NS}t"):
                if t.text:
                    text_parts.append(t.text)

            if text_parts:
                # Determine type from filename or relationship
                hdr_type = "default"
                if "first" in header_path.lower() or "first" in rel_type.lower():
                    hdr_type = "first_page"
                elif "even" in header_path.lower() or "even" in rel_type.lower():
                    hdr_type = "even_page"

                headers.append(
                    DocxHeaderFooter(type=hdr_type, text="".join(text_parts))
                )

    # Extract text from footer files
    for footer_path, rel_type in footer_files:
        root = ctx._header_footer_roots.get(footer_path)
        if root is not None:
            text_parts = []
            for t in root.findall(f".//{W_NS}t"):
                if t.text:
                    text_parts.append(t.text)

            if text_parts:
                # Determine type from filename or relationship
                ftr_type = "default"
                if "first" in footer_path.lower() or "first" in rel_type.lower():
                    ftr_type = "first_page"
                elif "even" in footer_path.lower() or "even" in rel_type.lower():
                    ftr_type = "even_page"

                footers.append(
                    DocxHeaderFooter(type=ftr_type, text="".join(text_parts))
                )

    return headers, footers


def _extract_paragraphs_from_context(ctx: _DocxContext) -> list[DocxParagraph]:
    """
    Extract paragraphs with their formatting and run information.

    Args:
        ctx: DocxContext with cached XML roots.

    Returns:
        List of DocxParagraph objects containing text, style, alignment,
        and a list of DocxRun objects with formatting details.
    """
    logger.debug("Extracting paragraphs")
    paragraphs = []

    body = ctx.document_body
    if body is None:
        return paragraphs

    style_map = ctx.styles

    # Only iterate through direct children of body to get top-level paragraphs
    # This excludes paragraphs nested inside tables (which are extracted separately)
    for p in body.findall(f"{W_NS}p"):
        # Get paragraph properties
        ppr = p.find(f"{W_NS}pPr")
        style_id = None
        alignment = None

        if ppr is not None:
            style_elem = ppr.find(f"{W_NS}pStyle")
            if style_elem is not None:
                style_id = style_elem.get(f"{W_NS}val")

            jc_elem = ppr.find(f"{W_NS}jc")
            if jc_elem is not None:
                alignment = jc_elem.get(f"{W_NS}val")

        # Get style name from style ID
        style_name = style_map.get(style_id, style_id) if style_id else None

        # Extract runs
        runs = []
        for r in p.findall(f".//{W_NS}r"):
            run_text_parts = []
            for t in r.findall(f".//{W_NS}t"):
                if t.text:
                    run_text_parts.append(t.text)

            run_text = "".join(run_text_parts)
            if not run_text:
                continue

            # Get run properties
            rpr = r.find(f"{W_NS}rPr")
            bold = None
            italic = None
            underline = None
            font_name = None
            font_size = None
            font_color = None

            if rpr is not None:
                bold_elem = rpr.find(f"{W_NS}b")
                if bold_elem is not None:
                    bold_val = bold_elem.get(f"{W_NS}val")
                    bold = bold_val != "0" if bold_val else True

                italic_elem = rpr.find(f"{W_NS}i")
                if italic_elem is not None:
                    italic_val = italic_elem.get(f"{W_NS}val")
                    italic = italic_val != "0" if italic_val else True

                underline_elem = rpr.find(f"{W_NS}u")
                if underline_elem is not None:
                    u_val = underline_elem.get(f"{W_NS}val")
                    underline = u_val and u_val != "none"

                # Font name from rFonts
                rfonts = rpr.find(f"{W_NS}rFonts")
                if rfonts is not None:
                    font_name = (
                        rfonts.get(f"{W_NS}ascii")
                        or rfonts.get(f"{W_NS}hAnsi")
                        or rfonts.get(f"{W_NS}cs")
                    )

                # Font size (in half-points)
                sz = rpr.find(f"{W_NS}sz")
                if sz is not None:
                    sz_val = sz.get(f"{W_NS}val")
                    if sz_val:
                        try:
                            font_size = int(sz_val) / 2  # Convert half-points to points
                        except ValueError:
                            pass

                # Font color
                color = rpr.find(f"{W_NS}color")
                if color is not None:
                    font_color = color.get(f"{W_NS}val")

            runs.append(
                DocxRun(
                    text=run_text,
                    bold=bold,
                    italic=italic,
                    underline=underline,
                    font_name=font_name,
                    font_size=font_size,
                    font_color=font_color,
                )
            )

        # Get full paragraph text
        para_text = "".join(run.text for run in runs)

        paragraphs.append(
            DocxParagraph(
                text=para_text,
                style=style_name,
                alignment=alignment,
                runs=runs,
            )
        )

    return paragraphs


def _extract_tables_from_context(ctx: _DocxContext) -> list[list[list[str]]]:
    """
    Extract tables as lists of lists of cell text.

    Args:
        ctx: DocxContext with cached XML roots.

    Returns:
        List of tables, where each table is a list of rows, and each row
        is a list of cell text strings.
    """
    logger.debug("Extracting tables")
    tables = []

    body = ctx.document_body
    if body is None:
        return tables

    for tbl in body.findall(f".//{W_NS}tbl"):
        table_data = []
        for tr in tbl.findall(f"{W_NS}tr"):
            row_data = []
            for tc in tr.findall(f"{W_NS}tc"):
                cell_text_parts = []
                for p in tc.findall(f".//{W_NS}p"):
                    para_text_parts = []
                    for t in p.findall(f".//{W_NS}t"):
                        if t.text:
                            para_text_parts.append(t.text)
                    cell_text_parts.append("".join(para_text_parts))
                row_data.append("\n".join(cell_text_parts))
            table_data.append(row_data)
        tables.append(table_data)

    return tables


def _extract_images_from_context(ctx: _DocxContext) -> list[DocxImage]:
    """
    Extract images from the document.

    Args:
        ctx: DocxContext with cached XML roots.

    Returns:
        List of DocxImage objects with binary data and metadata.
    """
    logger.debug("Extracting images")
    images = []

    rels = ctx.relationships

    for rel_id, rel_info in rels.items():
        rel_type = rel_info.get("type", "")
        target = rel_info.get("target", "")

        if "image" in rel_type.lower():
            image_path = "word/" + target
            try:
                img_data = ctx.get_image_data(image_path)
                if img_data is None:
                    continue

                # Determine content type from extension
                ext = target.split(".")[-1].lower()
                content_type_map = {
                    "png": "image/png",
                    "jpg": "image/jpeg",
                    "jpeg": "image/jpeg",
                    "gif": "image/gif",
                    "bmp": "image/bmp",
                    "tiff": "image/tiff",
                    "tif": "image/tiff",
                    "emf": "image/x-emf",
                    "wmf": "image/x-wmf",
                }
                content_type = content_type_map.get(ext, f"image/{ext}")

                images.append(
                    DocxImage(
                        rel_id=rel_id,
                        filename=target.split("/")[-1],
                        content_type=content_type,
                        data=io.BytesIO(img_data),
                        size_bytes=len(img_data),
                    )
                )
            except Exception as e:
                logger.debug(f"Image extraction failed for rel_id {rel_id} - {e}")
                images.append(DocxImage(rel_id=rel_id, error=str(e)))

    return images


def _extract_hyperlinks_from_context(ctx: _DocxContext) -> list[DocxHyperlink]:
    """
    Extract hyperlinks from the document.

    Args:
        ctx: DocxContext with cached XML roots.

    Returns:
        List of DocxHyperlink objects with text and URL.
    """
    logger.debug("Extracting hyperlinks")
    hyperlinks = []

    body = ctx.document_body
    if body is None:
        return hyperlinks

    rels = ctx.relationships

    for hyperlink in body.findall(f".//{W_NS}hyperlink"):
        r_id = hyperlink.get(f"{R_NS}id")
        if r_id and r_id in rels:
            rel_info = rels[r_id]
            if "hyperlink" in rel_info.get("type", "").lower():
                text_parts = []
                for t in hyperlink.findall(f".//{W_NS}t"):
                    if t.text:
                        text_parts.append(t.text)
                hyperlinks.append(
                    DocxHyperlink(
                        text="".join(text_parts), url=rel_info.get("target", "")
                    )
                )

    return hyperlinks


def _extract_formulas_from_context(ctx: _DocxContext) -> list[DocxFormula]:
    """
    Extract all mathematical formulas from the document as LaTeX.

    Args:
        ctx: DocxContext with cached XML roots.

    Returns:
        List of DocxFormula objects with:
        - latex: LaTeX representation of the formula
        - is_display: True for display equations (oMathPara), False for inline
    """
    logger.debug("Extracting formulas")
    formulas = []

    body = ctx.document_body
    if body is None:
        return formulas

    # Track oMath elements that are inside oMathPara to avoid duplicates
    omath_in_para = set()

    # First, find all oMathPara elements and their child oMath
    for omath_para in body.iter(f"{M_NS}oMathPara"):
        omath = omath_para.find(f"{M_NS}oMath")
        if omath is not None:
            omath_in_para.add(id(omath))
            latex = _DocxFullTextExtractor.omml_to_latex(omath)
            if latex.strip():
                formulas.append(DocxFormula(latex=latex, is_display=True))

    # Then find inline oMath elements (not in oMathPara)
    for omath in body.iter(f"{M_NS}oMath"):
        if id(omath) not in omath_in_para:
            latex = _DocxFullTextExtractor.omml_to_latex(omath)
            if latex.strip():
                formulas.append(DocxFormula(latex=latex, is_display=False))

    return formulas


def read_docx(
    file_like: io.BytesIO, path: str | None = None
) -> Generator[DocxContent, Any, None]:
    """
    Extract all relevant content from a Word .docx file.

    Primary entry point for DOCX file extraction. Parses the document structure,
    extracts text, formatting, and metadata using direct XML parsing of the
    docx ZIP archive.

    This function uses a generator pattern for API consistency with other
    extractors, even though DOCX files contain exactly one document.

    Args:
        file_like: BytesIO object containing the complete DOCX file data.
            The stream position is reset to the beginning before reading.
        path: Optional filesystem path to the source file. If provided,
            populates file metadata (filename, extension, folder) in the
            returned DocxContent.metadata.

    Yields:
        DocxContent: Single DocxContent object containing:
            - metadata: DocxMetadata with title, author, dates, revision
            - paragraphs: List of DocxParagraph with text and runs
            - tables: List of tables as 2D lists of cell text
            - headers, footers: Header/footer content by type
            - images: List of DocxImage with binary data
            - hyperlinks: List of DocxHyperlink with text and URL
            - footnotes, endnotes: Note content
            - comments: Comment content with author and date
            - sections: Page layout information
            - styles: List of style names used
            - formulas: List of DocxFormula as LaTeX
            - full_text: Complete text including formulas
            - base_full_text: Complete text without formulas

    Example:
        >>> import io
        >>> with open("report.docx", "rb") as f:
        ...     data = io.BytesIO(f.read())
        ...     for doc in read_docx(data, path="report.docx"):
        ...         print(f"Title: {doc.metadata.title}")
        ...         print(f"Tables: {len(doc.tables)}")
        ...         print(f"Images: {len(doc.images)}")
        ...         print(doc.full_text[:500])

    Performance Notes:
        - ZIP file is opened once and all XML is cached
        - All XML documents are parsed once and reused
        - Images are loaded into memory as BytesIO objects
        - Large documents may use significant memory
    """
    # Create context that opens ZIP once and caches all parsed XML
    ctx = _DocxContext(file_like)

    # === Core Properties (Metadata) ===
    metadata = _extract_metadata_from_context(ctx)

    # === Paragraphs ===
    paragraphs = _extract_paragraphs_from_context(ctx)

    # === Tables ===
    tables = _extract_tables_from_context(ctx)

    # === Headers and Footers ===
    headers, footers = _extract_header_footers_from_context(ctx)

    # === Images ===
    images = _extract_images_from_context(ctx)

    # === Hyperlinks ===
    hyperlinks = _extract_hyperlinks_from_context(ctx)

    # === Footnotes ===
    footnotes = _extract_footnotes_from_context(ctx)

    # === Endnotes ===
    endnotes = _extract_endnotes_from_context(ctx)

    # === Formulas ===
    formulas = _extract_formulas_from_context(ctx)

    # === Comments ===
    comments = _extract_comments_from_context(ctx)

    # === Sections (page layout) ===
    sections = _extract_sections_from_context(ctx)

    # === Styles used ===
    styles_set = set()
    for para in paragraphs:
        if para.style:
            styles_set.add(para.style)
    styles = list(styles_set)

    # === Full text (convenience) - use cached body for both ===
    body = ctx.document_body
    full_text = _DocxFullTextExtractor.extract_full_text_from_body(
        body=body, include_formulas=True
    )
    base_full_text = _DocxFullTextExtractor.extract_full_text_from_body(
        body=body, include_formulas=False
    )

    metadata.populate_from_path(path)

    logger.info(
        "Extracted DOCX: %d paragraphs, %d tables, %d images",
        len(paragraphs),
        len(tables),
        len(images),
    )

    yield DocxContent(
        metadata=metadata,
        paragraphs=paragraphs,
        tables=tables,
        headers=headers,
        footers=footers,
        images=images,
        hyperlinks=hyperlinks,
        footnotes=footnotes,
        endnotes=endnotes,
        comments=comments,
        sections=sections,
        styles=styles,
        formulas=formulas,
        full_text=full_text,
        base_full_text=base_full_text,
    )
