from __future__ import annotations

import copy
import html
import json
import os
import re
import shutil
import subprocess
import tempfile
from html.parser import HTMLParser
from contextlib import ExitStack
from dataclasses import dataclass, field
from importlib.resources import as_file, files
from pathlib import Path
from zipfile import BadZipFile, ZipFile
from xml.etree import ElementTree as ET

from docx import Document
from docx.enum.section import WD_ORIENT
from docx.enum.style import WD_STYLE_TYPE
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Emu, Inches, Pt, RGBColor, Twips
from docx.image.exceptions import UnrecognizedImageError

try:
    from .equations import latex_to_omath, latex_to_omath_para
except ImportError:  # pragma: no cover - script-style invocation
    from build_corpus.equations import latex_to_omath, latex_to_omath_para

try:
    from .frontmatter import inject_frontmatter_into_package, strip_mdk_frontmatter
except ImportError:  # pragma: no cover - script-style invocation
    from build_corpus.frontmatter import inject_frontmatter_into_package, strip_mdk_frontmatter

try:
    from .ooxml_postprocess import audit_package, sanitize_package
    from .house_style import (
        apply_chrome,
        dress_tables,
        evaluate_header_contrast,
        house_table_default,
        resolve_house_style,
        _set_paragraph_alignment,
    )
except ImportError:  # pragma: no cover - script-style invocation
    from build_corpus.ooxml_postprocess import audit_package, sanitize_package
    from build_corpus.house_style import (
        apply_chrome,
        dress_tables,
        evaluate_header_contrast,
        house_table_default,
        resolve_house_style,
        _set_paragraph_alignment,
    )

try:
    from .table_meta import (
        TABLE_META_COMMENT_RE,
        LEGACY_TABLE_STYLE_COMMENT_RE,
        parse_table_meta,
    )
except ImportError:  # pragma: no cover - script-style invocation
    from build_corpus.table_meta import (
        TABLE_META_COMMENT_RE,
        LEGACY_TABLE_STYLE_COMMENT_RE,
        parse_table_meta,
    )


InlineToken = tuple[str, str]

# Image targets python-docx can never embed as a picture (need an external
# render pipeline, e.g. headless-browser screenshot — NOT build-corpus's job).
UNRENDERABLE_IMAGE_EXTS = (".html", ".htm", ".jsx", ".tsx", ".js", ".svg", ".md")
# Vector/metafile formats embeddable only after a metafile->PNG conversion
# (available on Windows / where LibreOffice is present).
METAFILE_IMAGE_EXTS = (".emf", ".wmf")
STYLE_PACKAGE_COMMENT_RE = re.compile(r'<!--\s*build-corpus:word-style-package\b[^>]*\bpath="([^"]+)"[^>]*-->', re.IGNORECASE)
TABLE_STYLE_COMMENT_RE = re.compile(r'<!--\s*build-corpus:table-style\b[^>]*\bname="([^"]+)"[^>]*-->', re.IGNORECASE)
CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
NS_MATH = "http://schemas.openxmlformats.org/officeDocument/2006/math"
MARKUP_COMPAT_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
WORD_DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
PICTURE_NS = "http://schemas.openxmlformats.org/drawingml/2006/picture"
WORD14_NS = "http://schemas.microsoft.com/office/word/2010/wordml"
WP14_NS = "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"
WORD_PROCESSING_SHAPE_NS = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"
WORD_CANVAS_NS = "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas"
OFFICE_NS = "urn:schemas-microsoft-com:office:office"
VML_NS = "urn:schemas-microsoft-com:vml"
WORD10_NS = "urn:schemas-microsoft-com:office:word"
WORD2006_NS = "http://schemas.microsoft.com/office/word/2006/wordml"
MAC_OFFICE_NS = "http://schemas.microsoft.com/office/mac/office/2008/main"

OOXML_NAMESPACES = {
    "w": WORD_NS,
    "r": REL_NS,
    "m": NS_MATH,
    "mc": MARKUP_COMPAT_NS,
    "a": DRAWINGML_NS,
    "wp": WORD_DRAWING_NS,
    "pic": PICTURE_NS,
    "w14": WORD14_NS,
    "wp14": WP14_NS,
    "wps": WORD_PROCESSING_SHAPE_NS,
    "wpc": WORD_CANVAS_NS,
    "o": OFFICE_NS,
    "v": VML_NS,
    "w10": WORD10_NS,
    "wne": WORD2006_NS,
    "mo": MAC_OFFICE_NS,
}


def register_ooxml_namespaces(default_namespace: str | None = None) -> None:
    if default_namespace:
        ET.register_namespace("", default_namespace)
    for prefix, uri in OOXML_NAMESPACES.items():
        ET.register_namespace(prefix, uri)


def serialize_ooxml(root: ET.Element, default_namespace: str | None = None) -> bytes:
    register_ooxml_namespaces(default_namespace)
    return ET.tostring(root, encoding="utf-8", xml_declaration=True)


register_ooxml_namespaces()


def count_input_elements(markdown: str) -> dict:
    """Count source-side Markdown elements for input/output reconciliation.

    Leading blockquote markers are stripped first: a table/heading/code/equation
    inside a ``>`` quote is still rendered (now into a sidebar container), so it
    must be counted, or fidelity reconciliation falsely fails.
    """
    lines = [re.sub(r"^\s*(?:>\s?)+", "", ln) for ln in markdown.splitlines()]
    counts = {"tables": 0, "equations": 0, "images": 0,
              "code_blocks": 0, "headings": 0, "links": 0}
    in_fence = False
    index = 0
    while index < len(lines):
        stripped = lines[index].strip()
        if stripped.startswith("```"):
            counts["code_blocks"] += 1
            index += 1
            while index < len(lines) and not lines[index].strip().startswith("```"):
                index += 1
            index += 1
            continue
        if stripped == "$$":
            counts["equations"] += 1
            index += 1
            while index < len(lines) and lines[index].strip() != "$$":
                index += 1
            index += 1
            continue
        if stripped.startswith("$$") and stripped.endswith("$$") and len(stripped) > 4:
            # standalone single-line display equation (counted once; matches render_markdown)
            counts["equations"] += 1
            index += 1
            continue
        if stripped.startswith("#"):
            counts["headings"] += 1
        elif index + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[index + 1]):
            counts["tables"] += 1
        line_text = lines[index]
        for kind, token in scan_inline_tokens(line_text):
            if kind != "token":
                continue
            if token.startswith("!["):
                counts["images"] += 1
            elif token.startswith("["):
                counts["links"] += 1
            elif token.startswith("$"):
                counts["equations"] += 1
        index += 1
    return counts


def count_text_fixups(markdown: str) -> dict:
    """Count markdown-escape unescapes the exporter applies to body prose.

    Escapes inside fenced code blocks and inline code spans are not unescaped by
    the renderer, so they are excluded here to match actual behavior.
    """
    # strip fenced code blocks
    no_fences = re.sub(r"```.*?```", "", markdown, flags=re.DOTALL)
    # strip inline code spans
    no_code = re.sub(r"`[^`]+`", "", no_fences)
    matches = re.findall(r"\\([\\`*_{}\[\]()#+.!|$-])", no_code)
    return {"total": len(matches), "currency_unescaped": matches.count("$")}


def image_looks_like_svg(path: Path) -> bool:
    """Sniff a file's first bytes to detect SVG/XML content (even if mislabeled)."""
    try:
        head = path.read_bytes()[:512].lstrip()
    except OSError:
        return False
    lowered = head[:256].lower()
    return lowered.startswith(b"<svg") or (lowered.startswith(b"<?xml") and b"<svg" in head.lower())
BARE_URL_RE = re.compile(r"https?://[^\s<>()]+(?:\([^\s<>()]*\)[^\s<>()]*)*")
LIST_ITEM_RE = re.compile(r"^(\s*)([-*+]|\d+\.)\s+(.*)$")
TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$")
DEFAULT_TEMPLATE_FILENAME = "md-to-word-template.dotx"


def parse_column_alignments(separator_line: str) -> list[str | None]:
    """Extract per-column alignment from a Markdown table separator row (Fix E).

    ``|:---|`` → left, ``|:--:|`` → center, ``|---:|`` → right, ``|---|`` → None
    (no author signal — falls through to the Fix D classifier). Column order
    matches ``split_table_row`` so indices line up with the data cells.
    """
    aligns: list[str | None] = []
    for spec in split_table_row(separator_line):
        s = spec.strip()
        left, right = s.startswith(":"), s.endswith(":")
        if left and right:
            aligns.append("center")
        elif right:
            aligns.append("right")
        elif left:
            aligns.append("left")
        else:
            aligns.append(None)
    return aligns


@dataclass
class WordExportStats:
    paragraphs: int = 0
    headings: int = 0
    lists: int = 0
    tables: int = 0
    code_blocks: int = 0
    blockquotes: int = 0
    links: int = 0
    images: int = 0
    images_failed: int = 0
    equations: int = 0
    equations_omml: int = 0
    equations_fell_back: int = 0
    warnings: list[str] = field(default_factory=list)
    issues: list[dict] = field(default_factory=list)


def strip_fence(line: str) -> str:
    return line.strip()[3:].strip()


def split_table_row(line: str) -> list[str]:
    text = line.strip()
    if text.startswith("|"):
        text = text[1:]
    if text.endswith("|"):
        text = text[:-1]
    cells: list[str] = []
    buffer: list[str] = []
    escape = False
    in_code = False
    math_delimiter: str | None = None
    index = 0
    while index < len(text):
        char = text[index]
        if escape:
            buffer.append(char)
            escape = False
            index += 1
            continue
        if char == "\\":
            escape = True
            buffer.append(char)
            index += 1
            continue
        if math_delimiter:
            if text.startswith(math_delimiter, index):
                buffer.append(math_delimiter)
                index += len(math_delimiter)
                math_delimiter = None
                continue
            buffer.append(char)
            index += 1
            continue
        if char == "`":
            in_code = not in_code
            buffer.append(char)
            index += 1
            continue
        if not in_code and text.startswith("$$", index):
            close = find_math_close(text, index, "$$")
            if close is not None:
                math_delimiter = "$$"
            buffer.append("$$")
            index += 2
            continue
        if not in_code and char == "$" and is_inline_math_open(text, index):
            math_delimiter = "$"
            buffer.append(char)
            index += 1
            continue
        if char == "|" and not in_code:
            cells.append("".join(buffer).strip())
            buffer.clear()
            index += 1
            continue
        buffer.append(char)
        index += 1
    cells.append("".join(buffer).strip())
    return cells


def parse_bracket_token(text: str, start: int) -> tuple[str, int] | None:
    offset = 2 if text.startswith("![", start) else 1
    label_end = text.find("]", start + offset)
    if label_end == -1 or label_end + 1 >= len(text) or text[label_end + 1] != "(":
        return None
    cursor = label_end + 2
    depth = 1
    escape = False
    while cursor < len(text):
        char = text[cursor]
        if escape:
            escape = False
        elif char == "\\":
            escape = True
        elif char == "(":
            depth += 1
        elif char == ")":
            depth -= 1
            if depth == 0:
                return text[start : cursor + 1], cursor + 1
        cursor += 1
    return None


def find_math_close(text: str, start: int, delimiter: str) -> int | None:
    cursor = start + len(delimiter)
    limit = min(len(text), start + 202)
    while cursor < limit:
        if text[cursor] == "\n":
            return None
        if text.startswith(delimiter, cursor) and not is_escaped(text, cursor):
            if cursor > start + len(delimiter) and not text[cursor - 1].isspace():
                return cursor
            return None
        cursor += 1
    return None


def is_escaped(text: str, index: int) -> bool:
    slashes = 0
    cursor = index - 1
    while cursor >= 0 and text[cursor] == "\\":
        slashes += 1
        cursor -= 1
    return slashes % 2 == 1


def is_inline_math_open(text: str, index: int) -> bool:
    if is_escaped(text, index):
        return False
    if index + 1 >= len(text):
        return False
    next_char = text[index + 1]
    if next_char.isspace() or next_char.isdigit() or next_char in ",.":
        return False
    return find_math_close(text, index, "$") is not None


def is_emphasis_boundary(text: str, start: int, end: int) -> bool:
    before = text[start - 1] if start > 0 else " "
    after = text[end] if end < len(text) else " "
    return (
        (before.isspace() or before in "([{\"'/>")
        and (after.isspace() or after in ".,;:!?)]}\"'<")
    )


def is_valid_emphasis_span(text: str, start: int, close: int, marker: str) -> bool:
    inner = text[start + len(marker):close]
    if not inner:
        return False
    if inner[0].isspace() or inner[-1].isspace():
        return False
    return is_emphasis_boundary(text, start, close + len(marker))


def scan_inline_tokens(text: str) -> list[InlineToken]:
    tokens: list[InlineToken] = []
    buffer: list[str] = []
    index = 0

    def flush() -> None:
        if buffer:
            tokens.append(("text", "".join(buffer)))
            buffer.clear()

    while index < len(text):
        if text.startswith("![", index) or text.startswith("[", index):
            parsed = parse_bracket_token(text, index)
            if parsed is not None:
                flush()
                token, index = parsed
                if token.startswith("!["):
                    token, index = attach_image_attrs(token, text, index)
                tokens.append(("token", token))
                continue
        if text[index] == "`":
            end = text.find("`", index + 1)
            if end != -1:
                flush()
                tokens.append(("token", text[index : end + 1]))
                index = end + 1
                continue
        if text.startswith("$$", index):
            close = find_math_close(text, index, "$$")
            if close is not None:
                flush()
                tokens.append(("token", text[index : close + 2]))
                index = close + 2
                continue
        if text[index] == "$" and is_inline_math_open(text, index):
            close = find_math_close(text, index, "$")
            if close is not None:
                flush()
                tokens.append(("token", text[index : close + 1]))
                index = close + 1
                continue
        matched_emphasis = False
        for marker in ("***", "**", "*"):
            if text.startswith(marker, index):
                close = text.find(marker, index + len(marker))
                if close != -1 and is_valid_emphasis_span(text, index, close, marker):
                    flush()
                    tokens.append(("token", text[index : close + len(marker)]))
                    index = close + len(marker)
                    matched_emphasis = True
                break
        if matched_emphasis:
            continue
        buffer.append(text[index])
        index += 1
    flush()
    return tokens


def markdown_link_parts(token: str) -> tuple[str, str]:
    match = re.fullmatch(r"!?\[([^\]]*)\]\(([^)]+)\)", token)
    if not match:
        return token, ""
    return match.group(1), match.group(2).strip()


# --- Image layout directive: ![alt](img){ w=1/2 align=right wrap=square } -----
# One brace directive carries three orthogonal axes for a Markdown image:
#   width : percent of text width (`50%`), fraction (`1/2`), keyword
#           (`half`/`third`/`quarter`/`three-quarter`/`full`), explicit length
#           (`3in` / `2.5"` / `8cm` / `40mm`), or `bleed` (full page width).
#   align : left | right | center
#   wrap  : inline | standalone | square | tight   (square/tight = text-wrapping
#           float; inline/standalone stay in the text/paragraph flow).
IMAGE_ATTR_RE = re.compile(r"\s*\{([^{}]*)\}\s*$")
_WIDTH_KEYWORDS = {
    "full": 1.0,
    "three-quarter": 0.75, "three-quarters": 0.75,
    "two-thirds": 2 / 3, "two-third": 2 / 3,
    "half": 0.5,
    "third": 1 / 3, "one-third": 1 / 3,
    "quarter": 0.25, "one-quarter": 0.25,
}
_WIDTH_UNIT_EMU = {
    "in": 914400, "inch": 914400, "inches": 914400, '"': 914400,
    "cm": 360000, "mm": 36000,
}
_ALIGN_WORDS = {"left", "right", "center", "centre"}
_WRAP_WORDS = {"inline", "standalone", "square", "tight", "none", "float"}


def _looks_like_width(value: str) -> bool:
    v = value.strip().lower()
    if v in _WIDTH_KEYWORDS or v in ("bleed", "full-bleed"):
        return True
    if re.fullmatch(r"\d+(?:\.\d+)?%", v):
        return True
    if re.fullmatch(r"\d+\s*/\s*\d+", v):
        return True
    return re.fullmatch(r'\d+(?:\.\d+)?\s*(in|inch|inches|cm|mm|")', v) is not None


def parse_image_attrs(body: str) -> dict:
    """Parse the inside of an image ``{…}`` directive into {width, align, wrap}.

    Accepts ``key=value`` pairs and bare shorthand tokens (e.g. ``half``,
    ``right``, ``square``). Unknown tokens are ignored so prose never breaks.
    """
    attrs: dict[str, str] = {}
    for raw in body.replace(",", " ").split():
        part = raw.strip().lower()
        if not part:
            continue
        key, sep, val = part.partition("=")
        if not sep:  # bare shorthand token
            key, val = "", part
        if key in ("w", "width") or (not key and _looks_like_width(val)):
            attrs["width"] = val
        elif key in ("align", "alignment") or (not key and val in _ALIGN_WORDS):
            attrs["align"] = "center" if val in ("center", "centre") else val
        elif key == "wrap" or (not key and val in _WRAP_WORDS):
            attrs["wrap"] = val
    return attrs


def split_image_attrs(token: str) -> tuple[str, dict]:
    """Split ``![alt](img){…}`` into (``![alt](img)``, parsed-attrs)."""
    match = IMAGE_ATTR_RE.search(token)
    if not match:
        return token, {}
    return token[: match.start()], parse_image_attrs(match.group(1))


def attach_image_attrs(token: str, text: str, index: int) -> tuple[str, int]:
    """Consume an optional ``{…}`` layout directive right after an image token."""
    probe = index
    if probe < len(text) and text[probe] == " " and text.startswith("{", probe + 1):
        probe += 1
    if probe < len(text) and text[probe] == "{":
        close = text.find("}", probe + 1)
        if close != -1 and "\n" not in text[probe:close]:
            return token + text[probe : close + 1], close + 1
    return token, index


def _blockquote_has_block_content(quote_lines: list[str]) -> bool:
    """True when a blockquote holds block-level items a flat paragraph can't
    represent (table, list, fenced code, heading, standalone image, nested
    quote, or a blank-line-separated second paragraph)."""
    seen_text = False
    for i, raw in enumerate(quote_lines):
        s = raw.strip()
        if not s:
            if seen_text and any(x.strip() for x in quote_lines[i + 1:]):
                return True  # blank line between paragraphs
            continue
        seen_text = True
        if s.startswith(("#", ">", "|", "```", "![")):
            return True
        if LIST_ITEM_RE.match(raw) or TABLE_SEPARATOR_RE.match(raw):
            return True
    return False


def unescape_markdown_text(text: str) -> str:
    return re.sub(r"\\([\\`*_{}\[\]()#+.!|$-])", r"\1", text)


class HTMLTableParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__()
        self.rows: list[list[str]] = []
        self.current_row: list[str] | None = None
        self.current_cell: list[str] | None = None

    def handle_starttag(self, tag: str, attrs) -> None:  # type: ignore[override]
        normalized = tag.lower()
        if normalized == "tr":
            self.current_row = []
        elif normalized == "td":
            self.current_cell = []
        elif normalized == "br" and self.current_cell is not None:
            self.current_cell.append("\n")

    def handle_endtag(self, tag: str) -> None:  # type: ignore[override]
        normalized = tag.lower()
        if normalized == "td" and self.current_row is not None and self.current_cell is not None:
            self.current_row.append("".join(self.current_cell).strip())
            self.current_cell = None
        elif normalized == "tr" and self.current_row is not None:
            self.rows.append(self.current_row)
            self.current_row = None

    def handle_data(self, data: str) -> None:  # type: ignore[override]
        if self.current_cell is not None:
            self.current_cell.append(data)


def parse_html_table(markup: str) -> list[list[str]]:
    parser = HTMLTableParser()
    parser.feed(markup)
    parser.close()
    return [row for row in parser.rows if any(cell.strip() for cell in row)]


def packaged_template_resource():
    package_root = f"{__package__}.templates" if __package__ else "build_corpus.templates"
    return files(package_root).joinpath(DEFAULT_TEMPLATE_FILENAME)


def resolve_default_template_path() -> Path | None:
    try:
        packaged_template_resource()
    except ModuleNotFoundError:
        return None
    return Path(f"bundled:{DEFAULT_TEMPLATE_FILENAME}")


def set_cell_text(cell, text: str) -> None:
    lines = [segment.strip() for segment in text.replace("<br>", "\n").splitlines()]
    lines = [line for line in lines if line]
    if not lines:
        cell.text = ""
        return []
    return lines


def append_text_with_breaks(paragraph, text: str) -> None:
    text = unescape_markdown_text(text)
    parts = text.split("\n")
    for index, part in enumerate(parts):
        if part:
            paragraph.add_run(part)
        if index < len(parts) - 1:
            paragraph.add_run().add_break(WD_BREAK.LINE)


def split_trailing_url_punctuation(url: str) -> tuple[str, str]:
    trailing = []
    core = url
    while core and core[-1] in ".,;:!?":
        trailing.append(core[-1])
        core = core[:-1]
    return core, "".join(reversed(trailing))


def convert_windows_metafile_to_png(source: Path) -> Path | None:
    if os.name != "nt":
        return None
    target_dir = Path(tempfile.gettempdir()) / "build-corpus-image-fallbacks"
    target_dir.mkdir(parents=True, exist_ok=True)
    target = target_dir / f"{source.stem}.png"
    source_literal = str(source).replace("'", "''")
    target_literal = str(target).replace("'", "''")
    command = (
        "Add-Type -AssemblyName System.Drawing; "
        f"$img = [System.Drawing.Image]::FromFile('{source_literal}'); "
        f"$bmp = New-Object System.Drawing.Bitmap $img.Width, $img.Height; "
        "$gfx = [System.Drawing.Graphics]::FromImage($bmp); "
        "$gfx.DrawImage($img, 0, 0, $img.Width, $img.Height); "
        f"$bmp.Save('{target_literal}', [System.Drawing.Imaging.ImageFormat]::Png); "
        "$gfx.Dispose(); $bmp.Dispose(); $img.Dispose()"
    )
    result = subprocess.run(
        ["powershell", "-NoProfile", "-Command", command],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0 or not target.exists():
        return None
    return target


def set_paragraph_shading(paragraph, fill: str) -> None:
    paragraph_pr = paragraph._p.get_or_add_pPr()
    shading = paragraph_pr.find(qn("w:shd"))
    if shading is None:
        shading = OxmlElement("w:shd")
        paragraph_pr.append(shading)
    shading.set(qn("w:fill"), fill)


def set_paragraph_border(paragraph, color: str) -> None:
    paragraph_pr = paragraph._p.get_or_add_pPr()
    borders = paragraph_pr.find(qn("w:pBdr"))
    if borders is None:
        borders = OxmlElement("w:pBdr")
        paragraph_pr.append(borders)
    left = borders.find(qn("w:left"))
    if left is None:
        left = OxmlElement("w:left")
        borders.append(left)
    left.set(qn("w:val"), "single")
    left.set(qn("w:sz"), "10")
    left.set(qn("w:space"), "12")
    left.set(qn("w:color"), color)


def add_hyperlink(paragraph, text: str, url: str):
    part = paragraph.part
    relationship_id = part.relate_to(
        url,
        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
        is_external=True,
    )
    hyperlink = OxmlElement("w:hyperlink")
    hyperlink.set(qn("r:id"), relationship_id)

    run = OxmlElement("w:r")
    rpr = OxmlElement("w:rPr")

    color = OxmlElement("w:color")
    color.set(qn("w:val"), "2563EB")
    rpr.append(color)

    underline = OxmlElement("w:u")
    underline.set(qn("w:val"), "single")
    rpr.append(underline)
    run.append(rpr)

    text_node = OxmlElement("w:t")
    text_node.text = text
    run.append(text_node)
    hyperlink.append(run)
    paragraph._p.append(hyperlink)
    return hyperlink


def append_hyperlink_run(hyperlink, text: str, bold: bool = False, italic: bool = False, code: bool = False) -> None:
    run = OxmlElement("w:r")
    rpr = OxmlElement("w:rPr")

    color = OxmlElement("w:color")
    color.set(qn("w:val"), "2563EB")
    rpr.append(color)

    underline = OxmlElement("w:u")
    underline.set(qn("w:val"), "single")
    rpr.append(underline)

    if bold:
        rpr.append(OxmlElement("w:b"))
    if italic:
        rpr.append(OxmlElement("w:i"))

    fonts_needed = code
    if fonts_needed:
        fonts = OxmlElement("w:rFonts")
        fonts.set(qn("w:ascii"), "Consolas")
        fonts.set(qn("w:hAnsi"), "Consolas")
        fonts.set(qn("w:cs"), "Consolas")
        rpr.append(fonts)
        size = OxmlElement("w:sz")
        size.set(qn("w:val"), "20")
        rpr.append(size)

    run.append(rpr)
    text_node = OxmlElement("w:t")
    text_node.text = text
    run.append(text_node)
    hyperlink.append(run)


def parse_frontmatter_values(frontmatter: str | None) -> dict[str, str]:
    if not frontmatter:
        return {}
    values: dict[str, str] = {}
    lines = frontmatter.splitlines()
    in_block = False
    section: str | None = None
    for line in lines:
        stripped = line.strip()
        if stripped == "---":
            in_block = not in_block
            continue
        if not in_block or not stripped or stripped.startswith("#"):
            continue
        if line[:1].isspace() and section and ":" in stripped:
            key, value = stripped.split(":", 1)
            values[f"{section}.{key.strip()}"] = normalize_frontmatter_scalar(value.strip())
            continue
        section = None
        if ":" not in stripped:
            continue
        key, value = stripped.split(":", 1)
        key = key.strip()
        value = value.strip()
        if not value:
            section = key
            continue
        values[key] = normalize_frontmatter_scalar(value)
    return values


def normalize_frontmatter_scalar(value: str) -> str:
    value = value.strip()
    if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
        return value[1:-1]
    return value


def parse_measure(value: str):
    normalized = value.strip().lower()
    match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)\s*(in|inch|inches|cm|mm|pt)?", normalized)
    if not match:
        return None
    amount = float(match.group(1))
    unit = match.group(2) or "in"
    if unit in {"in", "inch", "inches"}:
        return Inches(amount)
    if unit == "cm":
        return Inches(amount / 2.54)
    if unit == "mm":
        return Inches(amount / 25.4)
    if unit == "pt":
        return Pt(amount)
    return None


def append_field_code(paragraph, instruction: str) -> None:
    begin_run = paragraph.add_run()
    begin = OxmlElement("w:fldChar")
    begin.set(qn("w:fldCharType"), "begin")
    begin_run._r.append(begin)

    instr_run = paragraph.add_run()
    instr = OxmlElement("w:instrText")
    instr.set(qn("xml:space"), "preserve")
    instr.text = f" {instruction} "
    instr_run._r.append(instr)

    separate_run = paragraph.add_run()
    separate = OxmlElement("w:fldChar")
    separate.set(qn("w:fldCharType"), "separate")
    separate_run._r.append(separate)

    paragraph.add_run(instruction.split()[0])

    end_run = paragraph.add_run()
    end = OxmlElement("w:fldChar")
    end.set(qn("w:fldCharType"), "end")
    end_run._r.append(end)


def append_text_with_fields(paragraph, text: str) -> None:
    cursor = 0
    for match in re.finditer(r"\{PAGE\}|\{NUMPAGES\}", text):
        if match.start() > cursor:
            paragraph.add_run(text[cursor:match.start()])
        token = match.group(0)
        append_field_code(paragraph, "PAGE" if token == "{PAGE}" else "NUMPAGES")
        cursor = match.end()
    if cursor < len(text):
        paragraph.add_run(text[cursor:])


def set_chrome_paragraph(paragraph, left: str | None, right: str | None) -> None:
    paragraph.text = ""
    if left:
        append_text_with_fields(paragraph, left)
    if left and right:
        paragraph.add_run("\t")
    if right:
        append_text_with_fields(paragraph, right)


def add_toc_field(document: Document) -> None:
    paragraph = document.add_paragraph()
    append_field_code(paragraph, r'TOC \o "1-3" \h \z \u')


def visible_text_runs(root: ET.Element) -> list[str]:
    texts: list[str] = []

    def walk(node: ET.Element, in_math: bool = False, hidden: bool = False) -> None:
        name = node.tag
        next_in_math = in_math or name.startswith(f"{{{NS_MATH}}}")
        next_hidden = hidden or node.find("./w:rPr/w:vanish", {"w": WORD_NS}) is not None
        if name == f"{{{WORD_NS}}}t" and not next_in_math and not next_hidden:
            if node.text:
                texts.append(node.text)
        for child in list(node):
            walk(child, next_in_math, next_hidden)

    walk(root)
    return texts


def set_picture_metadata(run, source_name: str) -> None:
    filename = Path(source_name).name
    try:
        doc_props = run._r.xpath(".//*[local-name()='docPr']")
    except Exception:
        doc_props = []
    for doc_prop in doc_props:
        doc_prop.set("name", filename)
        doc_prop.set("descr", filename)
        doc_prop.set("title", filename)


def package_part_path(base_part: str, target: str) -> str:
    if target.startswith("/"):
        return target.lstrip("/")
    return (Path(base_part).parent / target).as_posix()


def package_rel_target(from_part: str, to_part: str) -> str:
    from_dir = Path(from_part).parent
    return os.path.relpath(to_part, from_dir).replace("\\", "/")


def part_rels_path(part_name: str) -> str:
    part = Path(part_name)
    return f"{part.parent.as_posix()}/_rels/{part.name}.rels"


def next_relationship_id(root: ET.Element) -> str:
    used = {
        int(match.group(1))
        for rel in root.findall(f"{{{PACKAGE_REL_NS}}}Relationship")
        for match in [re.fullmatch(r"rId(\d+)", rel.attrib.get("Id", ""))]
        if match
    }
    index = 1
    while index in used:
        index += 1
    return f"rId{index}"


def unique_part_name(base_part: str, existing_parts: set[str], queued_parts: dict[str, bytes]) -> str:
    if base_part not in existing_parts and base_part not in queued_parts:
        return base_part
    part = Path(base_part)
    stem = part.stem
    suffix = part.suffix
    for index in range(1, 1000):
        candidate = f"{part.parent.as_posix()}/{stem}-style-{index}{suffix}"
        if candidate not in existing_parts and candidate not in queued_parts:
            return candidate
    raise RuntimeError(f"Unable to allocate unique DOCX part for {base_part}")


def copy_header_footer_relationship_parts(
    template_zip: ZipFile,
    output_names: set[str],
    queued_parts: dict[str, bytes],
    header_part_map: dict[str, str],
) -> None:
    template_names = set(template_zip.namelist())
    for template_part, output_part in header_part_map.items():
        rels_name = part_rels_path(template_part)
        if rels_name not in template_names:
            continue
        rels_root = ET.fromstring(template_zip.read(rels_name))
        for rel in rels_root.findall(f"{{{PACKAGE_REL_NS}}}Relationship"):
            if rel.attrib.get("TargetMode") == "External":
                continue
            target = rel.attrib.get("Target")
            if not target:
                continue
            source_part = package_part_path(template_part, target)
            if source_part not in template_names:
                continue
            dest_part = unique_part_name(source_part, output_names, queued_parts)
            queued_parts[dest_part] = template_zip.read(source_part)
            rel.set("Target", package_rel_target(output_part, dest_part))
        queued_parts[part_rels_path(output_part)] = serialize_ooxml(rels_root, PACKAGE_REL_NS)


def build_document_relationship_merge(
    out_zip: ZipFile,
    template_zip: ZipFile,
    header_part_map: dict[str, str],
) -> tuple[bytes | None, dict[str, str]]:
    if "word/_rels/document.xml.rels" not in template_zip.namelist() or "word/_rels/document.xml.rels" not in out_zip.namelist():
        return None, {}
    out_root = ET.fromstring(out_zip.read("word/_rels/document.xml.rels"))
    template_root = ET.fromstring(template_zip.read("word/_rels/document.xml.rels"))
    rel_id_map: dict[str, str] = {}
    for rel in template_root.findall(f"{{{PACKAGE_REL_NS}}}Relationship"):
        rel_type = rel.attrib.get("Type", "")
        if not rel_type.endswith(("/header", "/footer")):
            continue
        target = rel.attrib.get("Target")
        if not target:
            continue
        template_part = package_part_path("word/document.xml", target)
        output_part = header_part_map.get(template_part)
        if not output_part:
            continue
        new_id = next_relationship_id(out_root)
        rel_id_map[rel.attrib["Id"]] = new_id
        new_rel = ET.Element(f"{{{PACKAGE_REL_NS}}}Relationship", rel.attrib)
        new_rel.set("Id", new_id)
        new_rel.set("Target", package_rel_target("word/document.xml", output_part))
        out_root.append(new_rel)
    if not rel_id_map:
        return None, {}
    return serialize_ooxml(out_root, PACKAGE_REL_NS), rel_id_map


def build_document_section_merge(out_zip: ZipFile, template_zip: ZipFile, rel_id_map: dict[str, str]) -> bytes | None:
    if not rel_id_map:
        return None
    out_root = ET.fromstring(out_zip.read("word/document.xml"))
    out_body = out_root.find(f"{{{WORD_NS}}}body")
    if out_body is None:
        return None
    if "bc/sectPr.xml" in template_zip.namelist():
        merged_sectpr = ET.fromstring(template_zip.read("bc/sectPr.xml"))
    elif "build-corpus/section-properties.xml" in template_zip.namelist():
        merged_sectpr = ET.fromstring(template_zip.read("build-corpus/section-properties.xml"))
    elif "word/document.xml" in template_zip.namelist():
        template_root = ET.fromstring(template_zip.read("word/document.xml"))
        template_body = template_root.find(f"{{{WORD_NS}}}body")
        if template_body is None:
            return None
        template_sectpr = template_body.find(f"{{{WORD_NS}}}sectPr")
        if template_sectpr is None:
            return None
        merged_sectpr = copy.deepcopy(template_sectpr)
    else:
        return None
    for ref_tag in ("headerReference", "footerReference"):
        for ref in merged_sectpr.findall(f"{{{WORD_NS}}}{ref_tag}"):
            old_id = ref.attrib.get(f"{{{REL_NS}}}id")
            if old_id in rel_id_map:
                ref.set(f"{{{REL_NS}}}id", rel_id_map[old_id])
    out_sectpr = out_body.find(f"{{{WORD_NS}}}sectPr")
    if out_sectpr is not None:
        # Frontmatter precedence (BC-0.9-02): apply_frontmatter_chrome may have
        # authored its own header/footer references on the output sectPr. Those
        # are the document author's explicit intent and win per region type over
        # the template's chrome. For every (tag, w:type) the frontmatter set,
        # drop the template's ref of the same region, then graft the frontmatter
        # refs to the front of the merged sectPr (header/footer references must
        # precede all other sectPr children; sanitize_package normalizes order).
        type_attr = f"{{{WORD_NS}}}type"
        grafts: list[ET.Element] = []
        for ref_tag in ("headerReference", "footerReference"):
            fm_refs = out_sectpr.findall(f"{{{WORD_NS}}}{ref_tag}")
            if not fm_refs:
                continue
            fm_types = {ref.get(type_attr) or "default" for ref in fm_refs}
            for ref in list(merged_sectpr.findall(f"{{{WORD_NS}}}{ref_tag}")):
                if (ref.get(type_attr) or "default") in fm_types:
                    merged_sectpr.remove(ref)
            grafts.extend(copy.deepcopy(ref) for ref in fm_refs)
        for offset, ref in enumerate(grafts):
            merged_sectpr.insert(offset, ref)
        out_body.remove(out_sectpr)
    out_body.append(merged_sectpr)
    return serialize_ooxml(out_root)


def merge_content_types(out_zip: ZipFile, template_zip: ZipFile, extra_parts: dict[str, bytes]) -> bytes | None:
    if "[Content_Types].xml" not in out_zip.namelist() or "[Content_Types].xml" not in template_zip.namelist():
        return None
    out_root = ET.fromstring(out_zip.read("[Content_Types].xml"))
    template_root = ET.fromstring(template_zip.read("[Content_Types].xml"))
    defaults = {entry.attrib.get("Extension") for entry in out_root.findall(f"{{{CONTENT_TYPES_NS}}}Default")}
    overrides = {entry.attrib.get("PartName") for entry in out_root.findall(f"{{{CONTENT_TYPES_NS}}}Override")}
    needed_overrides = {"/" + name for name in extra_parts}
    needed_extensions = {Path(name).suffix.lstrip(".") for name in extra_parts if Path(name).suffix}
    for entry in template_root.findall(f"{{{CONTENT_TYPES_NS}}}Default"):
        extension = entry.attrib.get("Extension")
        if extension in needed_extensions and extension not in defaults:
            out_root.append(copy.deepcopy(entry))
            defaults.add(extension)
    for entry in template_root.findall(f"{{{CONTENT_TYPES_NS}}}Override"):
        part_name = entry.attrib.get("PartName")
        if part_name in needed_overrides and part_name not in overrides:
            out_root.append(copy.deepcopy(entry))
            overrides.add(part_name)
    return serialize_ooxml(out_root, CONTENT_TYPES_NS)


def build_style_package_merges(out_zip: ZipFile, template_zip: ZipFile) -> tuple[dict[str, bytes], bytes | None, bytes | None, bytes | None]:
    output_names = set(out_zip.namelist())
    template_names = set(template_zip.namelist())
    header_part_map: dict[str, str] = {}
    extra_parts: dict[str, bytes] = {}
    for name in sorted(template_names):
        if not re.fullmatch(r"word/(?:header|footer)\d+\.xml", name, flags=re.IGNORECASE):
            continue
        output_part = unique_part_name(name, output_names, extra_parts)
        header_part_map[name] = output_part
        extra_parts[output_part] = template_zip.read(name)
    if not header_part_map:
        return {}, None, None, None
    copy_header_footer_relationship_parts(template_zip, output_names, extra_parts, header_part_map)
    merged_rels, rel_id_map = build_document_relationship_merge(out_zip, template_zip, header_part_map)
    merged_document = build_document_section_merge(out_zip, template_zip, rel_id_map)
    merged_content_types = merge_content_types(out_zip, template_zip, extra_parts)
    return extra_parts, merged_rels, merged_document, merged_content_types


def set_table_style_id(table, style_id: str) -> None:
    tbl_pr = table._tbl.tblPr
    for existing in list(tbl_pr.findall(qn("w:tblStyle"))):
        tbl_pr.remove(existing)
    tbl_style = OxmlElement("w:tblStyle")
    tbl_style.set(qn("w:val"), style_id)
    tbl_pr.insert(0, tbl_style)


def _parse_border_meta(borders: str | None) -> tuple[str, str, str] | None:
    """Split a 'style,size,color' meta string into (val, sz, color)."""
    if not borders:
        return None
    parts = [p.strip() for p in borders.split(",")]
    val = parts[0] if parts and parts[0] else "single"
    sz = parts[1] if len(parts) > 1 and parts[1] else "4"
    color = parts[2] if len(parts) > 2 and parts[2] else "000000"
    return val, sz, color.lstrip("#")


def _make_borders_element(tag: str, borders: str | None) -> OxmlElement | None:
    """Build a w:tblBorders or w:tcBorders element from a border meta string."""
    parsed = _parse_border_meta(borders)
    if parsed is None:
        return None
    val, sz, color = parsed
    container = OxmlElement(tag)
    edges = ("top", "left", "bottom", "right", "insideH", "insideV")
    for edge in edges:
        edge_el = OxmlElement(f"w:{edge}")
        edge_el.set(qn("w:val"), val)
        edge_el.set(qn("w:sz"), sz)
        edge_el.set(qn("w:space"), "0")
        edge_el.set(qn("w:color"), color)
        container.append(edge_el)
    return container


def _iter_distinct_cells(row):
    """Yield ``(index, cell)`` for each DISTINCT underlying ``<w:tc>`` in a row.

    ``python-docx`` ``row.cells`` returns the SAME cell object repeated for every
    grid position a horizontally-merged cell spans. Mutating per returned cell
    would write ``tcW``/``shd``/``tcMar`` onto that ``<w:tc>`` once per spanned
    column — duplicating the child elements. Deduping by ``id(cell._tc)`` ensures
    each distinct ``<w:tc>`` is touched exactly once. ``index`` is the grid index
    of the first position the cell occupies.
    """
    seen: set[int] = set()
    for index, cell in enumerate(row.cells):
        key = id(cell._tc)
        if key in seen:
            continue
        seen.add(key)
        yield index, cell


def set_table_grid(table, widths_twips: list[int]) -> None:
    """Force an explicit w:tblGrid (col widths in twips) and per-cell w:tcW."""
    if not widths_twips:
        return
    tbl = table._tbl
    grid = tbl.find(qn("w:tblGrid"))
    if grid is None:
        grid = OxmlElement("w:tblGrid")
        # tblGrid must follow tblPr.
        tbl_pr = tbl.find(qn("w:tblPr"))
        if tbl_pr is not None:
            tbl_pr.addnext(grid)
        else:
            tbl.insert(0, grid)
    for existing in list(grid):
        grid.remove(existing)
    for width in widths_twips:
        col = OxmlElement("w:gridCol")
        col.set(qn("w:w"), str(int(width)))
        grid.append(col)
    # Mirror onto each cell's tcW so Word honors per-column widths. Dedupe merged
    # cells so a spanned <w:tc> gets exactly one tcW.
    for row in table.rows:
        for index, cell in _iter_distinct_cells(row):
            if index >= len(widths_twips):
                continue
            tc_pr = cell._tc.get_or_add_tcPr()
            for existing in list(tc_pr.findall(qn("w:tcW"))):
                tc_pr.remove(existing)
            tc_w = OxmlElement("w:tcW")
            tc_w.set(qn("w:w"), str(int(widths_twips[index])))
            tc_w.set(qn("w:type"), "dxa")
            tc_pr.append(tc_w)


def set_table_borders(table, borders: str | None) -> None:
    if not borders:
        return
    element = _make_borders_element("w:tblBorders", borders)
    if element is None:
        return
    tbl_pr = table._tbl.tblPr
    for existing in list(tbl_pr.findall(qn("w:tblBorders"))):
        tbl_pr.remove(existing)
    tbl_pr.append(element)


def _apply_shd_to_cell(cell, fill: str) -> None:
    """Replace any existing w:shd on a single cell with a clear fill."""
    tc_pr = cell._tc.get_or_add_tcPr()
    for existing in list(tc_pr.findall(qn("w:shd"))):
        tc_pr.remove(existing)
    shd = OxmlElement("w:shd")
    shd.set(qn("w:val"), "clear")
    shd.set(qn("w:color"), "auto")
    shd.set(qn("w:fill"), fill)
    tc_pr.append(shd)


def set_table_shade(table, fill) -> None:
    """Apply shade fill(s) to cells (table-level shd is not widely honored).

    ``fill`` accepts the two normalized ``shade`` forms from ``table_meta``:

    * a **string** hex fill -> applied to EVERY cell (back-compatible).
    * a **dict** ``{"header": <hex>, "body": <hex>?}`` -> ``header`` shades the
      FIRST row only; ``body`` (optional) shades the remaining rows. Missing keys
      leave those rows undressed.

    Merged cells are deduped by underlying ``<w:tc>`` so a horizontally-spanned
    cell gets exactly one ``w:shd`` (no duplicate children).
    """
    if not fill:
        return
    rows = list(table.rows)
    if not rows:
        return

    if isinstance(fill, dict):
        header_fill = fill.get("header")
        body_fill = fill.get("body")
        if header_fill:
            header_fill = header_fill.lstrip("#")
            for _index, cell in _iter_distinct_cells(rows[0]):
                _apply_shd_to_cell(cell, header_fill)
        if body_fill:
            body_fill = body_fill.lstrip("#")
            for row in rows[1:]:
                for _index, cell in _iter_distinct_cells(row):
                    _apply_shd_to_cell(cell, body_fill)
        return

    fill = fill.lstrip("#")
    for row in rows:
        for _index, cell in _iter_distinct_cells(row):
            _apply_shd_to_cell(cell, fill)


def set_table_cell_margins(table, pad: dict | None) -> None:
    """Apply uniform cell padding (twips) to every cell via w:tcMar."""
    if not pad:
        return
    for row in table.rows:
        for _index, cell in _iter_distinct_cells(row):
            tc_pr = cell._tc.get_or_add_tcPr()
            for existing in list(tc_pr.findall(qn("w:tcMar"))):
                tc_pr.remove(existing)
            mar = OxmlElement("w:tcMar")
            for edge in ("top", "left", "bottom", "right"):
                if edge not in pad:
                    continue
                edge_el = OxmlElement(f"w:{edge}")
                edge_el.set(qn("w:w"), str(int(pad[edge])))
                edge_el.set(qn("w:type"), "dxa")
                mar.append(edge_el)
            if len(mar):
                tc_pr.append(mar)


def set_table_look(table, pairs) -> None:
    """Apply a verbatim ``<w:tblLook>`` attribute list to a table (Fix G)."""
    if not pairs:
        return
    tbl_pr = table._tbl.tblPr
    for existing in list(tbl_pr.findall(qn("w:tblLook"))):
        tbl_pr.remove(existing)
    look = OxmlElement("w:tblLook")
    for name, value in pairs:
        look.set(qn("w:" + name), str(value))
    tbl_pr.append(look)


def set_cells_cnf_style(table, cells) -> None:
    """Apply per-cell ``<w:cnfStyle>`` role bits keyed by row/col (Fix I)."""
    if not cells:
        return
    rows = table.rows
    for row_key, cols in cells.items():
        try:
            row_index = int(row_key)
        except (TypeError, ValueError):
            continue
        if row_index >= len(rows):
            continue
        tcs = rows[row_index]._tr.findall(qn("w:tc"))
        for col_key, cell_meta in cols.items():
            try:
                col_index = int(col_key)
            except (TypeError, ValueError):
                continue
            if col_index >= len(tcs):
                continue
            pairs = (cell_meta or {}).get("cnfStyle")
            if not pairs:
                continue
            tc_pr = tcs[col_index].get_or_add_tcPr()
            for existing in list(tc_pr.findall(qn("w:cnfStyle"))):
                tc_pr.remove(existing)
            cnf = OxmlElement("w:cnfStyle")
            for name, value in pairs:
                cnf.set(qn("w:" + name), str(value))
            tc_pr.insert(0, cnf)  # cnfStyle is the first tcPr child


def apply_table_meta(table, meta: dict, document=None) -> None:
    """Apply a canonical table-meta dict to a built python-docx table.

    Order: style first (so explicit border/shade overrides win), then grid,
    borders, shade, padding, then role markers (tblLook / per-cell cnfStyle).
    Tolerant of absent keys.
    """
    if not meta:
        return
    style = meta.get("style")
    if style:
        if document is not None and style in document.styles:
            table.style = style
        else:
            set_table_style_id(table, style)
    widths = [c["w"] for c in (meta.get("cols") or []) if c.get("w") is not None]
    if widths:
        set_table_grid(table, widths)
    set_table_borders(table, meta.get("borders"))
    set_table_shade(table, meta.get("shade"))
    set_table_cell_margins(table, meta.get("pad"))
    set_table_look(table, meta.get("tblLook"))
    set_cells_cnf_style(table, meta.get("cells"))


class MarkdownToDocxExporter:
    def __init__(
        self,
        input_path: Path,
        output_dir: Path,
        output_docx: Path | None = None,
        report_path: Path | None = None,
        template_path: Path | None = None,
        house_style_values: dict | None = None,
    ):
        # BC-0.9-11: palette/house-style values supplied by the caller's
        # build-corpus.config.json (frontmatter still overrides these per-key).
        self.house_style_values: dict = dict(house_style_values or {})
        self.input_path = input_path
        self.output_dir = output_dir
        self.output_docx = output_docx or (output_dir / f"{input_path.stem}.docx")
        self.report_path = report_path or (output_dir / "export-report.json")
        self._template_resource_stack = ExitStack()
        self.template_path = self.resolve_template_path(template_path)
        self.use_template_styles = self.template_path is not None
        self.stats = WordExportStats()
        self.pending_table_style: str | None = None
        self.pending_table_meta: dict | None = None
        self.house_style: str | None = None
        # Image layout metrics; recomputed from the live section in
        # render_markdown. Defaults cover a letter page with 0.9" side margins.
        self.content_width_emu: int = int(Inches(6.7))
        self.page_width_emu: int = int(Inches(8.5))

    def export(self) -> dict:
        try:
            self.output_dir.mkdir(parents=True, exist_ok=True)
            if self.template_path and not self.template_path.exists():
                raise FileNotFoundError(f"Word template not found: {self.template_path}")

            document = Document()
            if not self.template_path:
                self.apply_modern_styles(document)
            self.ensure_custom_styles(document)

            raw_markdown = self.input_path.read_text(encoding="utf-8")
            frontmatter, markdown = strip_mdk_frontmatter(raw_markdown)
            self.apply_frontmatter_chrome(document, frontmatter)
            self.render_markdown(document, markdown)
            document.save(self.output_docx)
            if self.template_path:
                self.apply_template_package(self.output_docx, self.template_path)
            if frontmatter:
                inject_frontmatter_into_package(self.output_docx, frontmatter)
            self.apply_house_style(frontmatter)
            self.apply_page_margins(frontmatter)
            # Correctness gate: neutralize template-borne float measurements,
            # child-order violations and dead font embeds on the final package.
            self.sanitize_report = sanitize_package(self.output_docx)
            self.validate_output_package()

            input_counts = count_input_elements(markdown)
            text_fixups = count_text_fixups(markdown)
            reconciliation = self.build_reconciliation(input_counts)
            fidelity_ok = all(row["ok"] for row in reconciliation.values()) and not self.stats.issues

            report = {
                "input": str(self.input_path),
                "output": str(self.output_docx),
                "template": str(self.template_path) if self.template_path else None,
                "fidelity_ok": fidelity_ok,
                "reconciliation": reconciliation,
                "text_fixups": text_fixups,
                "issues": self.stats.issues,
                "stats": self.stats.__dict__,
                "sanitize": getattr(self, "sanitize_report", {}),
            }
            # BC-0.9-14: surface the house-style dressing audit when it ran. Key
            # is absent (not null) when no house_style was declared, so existing
            # report consumers (e.g. the MDE editor) see no shape change.
            if getattr(self, "house_style_report", None):
                report["house_style_report"] = self.house_style_report
            self.report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
            self._print_digest(reconciliation, text_fixups, fidelity_ok)
            return report
        finally:
            self._template_resource_stack.close()

    def build_reconciliation(self, input_counts: dict) -> dict:
        """Compare source-side element counts to what was emitted."""
        s = self.stats
        equations_in = input_counts["equations"]
        return {
            "tables": {
                "in": input_counts["tables"], "out": s.tables,
                "ok": input_counts["tables"] == s.tables,
            },
            "equations": {
                "in": equations_in, "out_omml": s.equations_omml,
                "fell_back": s.equations_fell_back,
                "ok": s.equations_omml == equations_in and s.equations_fell_back == 0,
            },
            "images": {
                "in": input_counts["images"], "out": s.images, "failed": s.images_failed,
                "ok": s.images_failed == 0 and s.images == input_counts["images"],
            },
            "code_blocks": {
                "in": input_counts["code_blocks"], "out": s.code_blocks,
                "ok": input_counts["code_blocks"] == s.code_blocks,
            },
            "headings": {
                "in": input_counts["headings"], "out": s.headings,
                "ok": input_counts["headings"] == s.headings,
            },
            "links": {
                "in": input_counts["links"], "out": s.links,
                "ok": input_counts["links"] == s.links,
            },
        }

    def _print_digest(self, reconciliation: dict, text_fixups: dict, fidelity_ok: bool) -> None:
        """Print a one-line human-readable fidelity summary to stdout."""
        parts = []
        for name, row in reconciliation.items():
            out = row.get("out_omml", row.get("out", 0))
            mark = "OK" if row["ok"] else "!!"
            label = f"[{mark}] {name} {out}/{row['in']}"
            if name == "equations" and row["fell_back"]:
                label += f" ({row['fell_back']} fell back)"
            if name == "images" and row.get("failed"):
                label += f" ({row['failed']} failed)"
            parts.append(label)
        if text_fixups["total"]:
            parts.append(f"text_fixups {text_fixups['total']} "
                         f"({text_fixups['currency_unescaped']} currency)")
        bad_lines = sorted({i["line"] for i in self.stats.issues if i.get("line")})
        if bad_lines:
            parts.append("issue lines: " + ",".join(str(n) for n in bad_lines))
        print("  ".join(parts) + f"  -> fidelity_ok={str(fidelity_ok).lower()}")

    def apply_frontmatter_chrome(self, document: Document, frontmatter: str | None) -> None:
        values = parse_frontmatter_values(frontmatter)
        if not values:
            return
        self.house_style = values.get("house_style") or None
        section = document.sections[0]
        if values.get("title_page", "").lower() == "true" or values.get("different_first_page_header_footer", "").lower() == "true":
            section.different_first_page_header_footer = True

        page_size = values.get("page_size", "").lower()
        if page_size in {"letter", "us-letter"}:
            section.page_width = Inches(8.5)
            section.page_height = Inches(11)
        elif page_size == "a4":
            section.page_width = Inches(8.27)
            section.page_height = Inches(11.69)

        if values.get("orientation", "").lower() == "landscape":
            section.orientation = WD_ORIENT.LANDSCAPE
            if section.page_height > section.page_width:
                section.page_width, section.page_height = section.page_height, section.page_width
        elif values.get("orientation", "").lower() == "portrait":
            section.orientation = WD_ORIENT.PORTRAIT
            if section.page_width > section.page_height:
                section.page_width, section.page_height = section.page_height, section.page_width

        margin_map = {
            "margins.top": "top_margin",
            "margins.right": "right_margin",
            "margins.bottom": "bottom_margin",
            "margins.left": "left_margin",
        }
        for key, attr_name in margin_map.items():
            if key in values:
                measure = parse_measure(values[key])
                if measure is not None:
                    setattr(section, attr_name, measure)

        header_left = values.get("header_left")
        header_right = values.get("header_right")
        footer_left = values.get("footer_left")
        footer_right = values.get("footer_right")
        if header_left or header_right:
            set_chrome_paragraph(section.header.paragraphs[0], header_left, header_right)
        if footer_left or footer_right:
            set_chrome_paragraph(section.footer.paragraphs[0], footer_left, footer_right)

    def _read_template_pgmar(self, template_path) -> dict | None:
        """Read ``<w:sectPr><w:pgMar>`` (twips) from a .dotx/.docx template."""
        try:
            with ZipFile(str(template_path)) as archive:
                xml = archive.read("word/document.xml")
        except (KeyError, OSError, BadZipFile):
            return None
        try:
            root = ET.fromstring(xml)
        except ET.ParseError:
            return None
        w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
        pg = root.find(f".//{w}sectPr/{w}pgMar")
        if pg is None:
            return None
        out: dict[str, int] = {}
        for side in ("top", "bottom", "left", "right"):
            val = pg.get(f"{w}{side}")
            if val is not None:
                try:
                    out[side] = int(val)
                except ValueError:
                    pass
        return out or None

    def apply_page_margins(self, frontmatter: str | None) -> None:
        """BC-0.9-01: honor the template's page margins, with a frontmatter escape.

        Precedence per side: frontmatter ``page.<side>_in`` > config
        ``house_style_values['page.<side>_in']`` > template ``<w:pgMar>`` >
        existing default. A side nobody declares is left untouched. No-op when
        neither a template nor any page override is present.
        """
        values = dict(self.house_style_values)
        values.update(parse_frontmatter_values(frontmatter) or {})
        sides = ("top", "bottom", "left", "right")
        overrides = {s: values.get(f"page.{s}_in") for s in sides}
        overrides = {s: v for s, v in overrides.items() if v not in (None, "")}
        template_margins = (
            self._read_template_pgmar(self.template_path) if self.template_path else None
        )
        if not overrides and not template_margins:
            return
        try:
            document = Document(str(self.output_docx))
            section = document.sections[0]
            attr_of = {
                "top": "top_margin", "bottom": "bottom_margin",
                "left": "left_margin", "right": "right_margin",
            }
            for side in sides:
                if side in overrides:
                    try:
                        setattr(section, attr_of[side], Inches(float(overrides[side])))
                    except (TypeError, ValueError):
                        pass
                elif template_margins and side in template_margins:
                    setattr(section, attr_of[side], Twips(int(template_margins[side])))
            document.save(str(self.output_docx))
        except Exception as exc:  # pragma: no cover - margins must never fail a build
            self.stats.issues.append({
                "type": "page-margins", "reason": "apply-failed", "message": str(exc),
            })

    def apply_house_style(self, frontmatter: str | None) -> None:
        """Apply declarative table theming + header/footer chrome if requested.

        Driven by frontmatter: ``house_style: <profile>`` plus optional
        ``table.*`` / ``header_left`` / ``footer_center`` overrides and
        ``style_vars.*`` substitution variables. No-op when nothing is declared.

        BC-0.9-11: values from the caller's ``build-corpus.config.json``
        (``house_style_values``) form the base; frontmatter overrides them
        per-key, so a document with no frontmatter can still be dressed from
        config alone.
        """
        # Config values are the base; frontmatter overrides per-key.
        values = dict(self.house_style_values)
        values.update(parse_frontmatter_values(frontmatter) or {})
        if not values:
            return
        style, style_vars = resolve_house_style(values)
        if style is None:
            return
        # house_style may have arrived via config (frontmatter chrome never saw
        # it) — record it so the inline-default check below is accurate.
        self.house_style = values.get("house_style") or self.house_style
        # Reconciliation: when this house style carries an inline table-meta
        # default (the DD-6 ``resolve_table_meta`` path already dressed every
        # unstyled table during emission — e.g. futurville-asp's header-only
        # maroon), the ``dress_tables`` post-pass must NOT re-theme tables or it
        # double-dresses them (zebra-banding the body rows the inline contract
        # left plain). House styles without an inline table default (navy,
        # oliizoi) still get the full ``dress_tables`` theming pass.
        # Header/footer chrome from ``apply_chrome`` always applies.
        inline_tables_owned = house_table_default(self.house_style) is not None

        def _flag(key: str, default: bool) -> bool:
            v = values.get(key)
            if v is None:
                return default
            return str(v).strip().lower() in {"1", "true", "yes", "on"}

        # Fix C: WCAG header contrast guard (may auto-invert header text). Runs
        # regardless of the dressing path so a low-contrast header is always
        # flagged. Warnings land in stats.warnings (report) and the audit block.
        style, contrast_warnings = evaluate_header_contrast(
            style, auto_contrast=_flag("table.auto_contrast", False)
        )
        self.house_style_warnings = list(contrast_warnings)
        for w in contrast_warnings:
            self.stats.warnings.append(w)

        try:
            document = Document(str(self.output_docx))
            if inline_tables_owned:
                dress = {"tables_dressed": 0, "tables_skipped_explicit_meta": 0}
            else:
                dress = dress_tables(
                    document, style,
                    auto_justify=_flag("table.auto_justify", True),
                    respect_explicit_meta=_flag("table.respect_explicit_meta", True),
                    repeat_header=_flag("table.repeat_header", True),
                )
            chrome = apply_chrome(document, style, style_vars)
            document.save(str(self.output_docx))
            self.house_style_report = {
                "profile": style.name,
                "tables_dressed": dress["tables_dressed"],
                "tables_skipped_explicit_meta": dress["tables_skipped_explicit_meta"],
                "tables_via_inline_meta": inline_tables_owned,
                "warnings": list(getattr(self, "house_style_warnings", [])),
                **chrome,
            }
        except Exception as exc:  # pragma: no cover - dressing must never hard-fail a build
            self.stats.issues.append({
                "type": "house-style", "reason": "dressing-failed", "message": str(exc),
            })

    def validate_output_package(self) -> None:
        latex_leak_re = re.compile(r"\\(?:frac|sum|int|operatorname|begin|end|sqrt|eta|times)\b")
        try:
            with ZipFile(self.output_docx) as archive:
                for name in archive.namelist():
                    if not name.endswith(".xml"):
                        continue
                    try:
                        root = ET.fromstring(archive.read(name))
                    except ET.ParseError as exc:
                        self.stats.issues.append({
                            "type": "package",
                            "part": name,
                            "reason": "xml-parse-failed",
                            "message": str(exc),
                        })
                        continue
                    if name == "word/document.xml":
                        for text in visible_text_runs(root):
                            if latex_leak_re.search(text):
                                self.stats.issues.append({
                                    "type": "latex-leak",
                                    "part": name,
                                    "source": text[:120],
                                    "reason": "visible-latex-outside-math",
                                })
        except OSError as exc:
            self.stats.issues.append({
                "type": "package",
                "reason": "package-open-failed",
                "message": str(exc),
            })

        # OOXML correctness gate: surface any defect the sanitizer could not
        # repair (float measurements, child-order, dead font embeds). After
        # sanitize_package this should be empty; a non-empty result fails fidelity.
        for issue in audit_package(self.output_docx):
            self.stats.issues.append(issue)

    def resolve_template_path(self, template_path: Path | None) -> Path | None:
        if template_path is not None:
            return Path(template_path)

        roundtrip_template = self.resolve_roundtrip_template_path()
        if roundtrip_template is not None:
            return roundtrip_template

        try:
            return self._template_resource_stack.enter_context(as_file(packaged_template_resource()))
        except (FileNotFoundError, ModuleNotFoundError):
            return None

    def resolve_roundtrip_template_path(self) -> Path | None:
        sidecar_template = self.input_path.with_suffix(".wordstyle") / "style-package.docx"
        if sidecar_template.exists():
            return sidecar_template
        try:
            head = self.input_path.read_text(encoding="utf-8")[:4096]
        except OSError:
            return None
        match = STYLE_PACKAGE_COMMENT_RE.search(head)
        if not match:
            return None
        candidate = Path(match.group(1))
        if not candidate.is_absolute():
            candidate = self.input_path.parent / candidate
        return candidate if candidate.exists() else None

    def apply_modern_styles(self, document: Document) -> None:
        section = document.sections[0]
        section.top_margin = Inches(0.8)
        section.bottom_margin = Inches(0.8)
        section.left_margin = Inches(0.9)
        section.right_margin = Inches(0.9)

        normal = document.styles["Normal"]
        normal.font.name = "Aptos"
        normal.font.size = Pt(11)
        normal.font.color.rgb = RGBColor(31, 41, 55)
        normal.paragraph_format.space_after = Pt(8)
        normal.paragraph_format.line_spacing = 1.15

        for level, size in ((1, 22), (2, 17), (3, 14), (4, 12)):
            style = document.styles[f"Heading {level}"]
            style.font.name = "Aptos Display"
            style.font.size = Pt(size)
            style.font.bold = True
            style.font.color.rgb = RGBColor(15, 23, 42)
            style.paragraph_format.space_before = Pt(12 if level == 1 else 10)
            style.paragraph_format.space_after = Pt(4)

        self.ensure_custom_styles(document)

    def ensure_custom_styles(self, document: Document) -> None:
        if self.use_template_styles:
            return
        if "BuildCorpus Code" not in document.styles:
            style = document.styles.add_style("BuildCorpus Code", WD_STYLE_TYPE.PARAGRAPH)
            style.base_style = document.styles["Normal"]
            style.font.name = "Consolas"
            style.font.size = Pt(10)
            style.paragraph_format.left_indent = Inches(0.2)
            style.paragraph_format.right_indent = Inches(0.2)
            style.paragraph_format.space_before = Pt(4)
            style.paragraph_format.space_after = Pt(6)

        if "BuildCorpus Quote" not in document.styles:
            style = document.styles.add_style("BuildCorpus Quote", WD_STYLE_TYPE.PARAGRAPH)
            style.base_style = document.styles["Normal"]
            style.font.italic = True
            style.font.color.rgb = RGBColor(71, 85, 105)
            style.paragraph_format.left_indent = Inches(0.35)
            style.paragraph_format.space_after = Pt(6)

    def render_markdown(self, document: Document, markdown: str) -> None:
        self._compute_layout_metrics(document)
        lines = markdown.splitlines()
        index = 0
        while index < len(lines):
            line = lines[index]
            self.current_line = index + 1
            stripped = line.strip()

            if not stripped:
                index += 1
                continue

            if TABLE_META_COMMENT_RE.fullmatch(stripped):
                self.pending_table_meta = parse_table_meta(stripped)
                self.pending_table_style = self.pending_table_meta.get("style")
                index += 1
                continue

            table_style_match = TABLE_STYLE_COMMENT_RE.fullmatch(stripped)
            if table_style_match:
                self.pending_table_style = html.unescape(table_style_match.group(1))
                index += 1
                continue

            if stripped.startswith("<!--") and stripped.endswith("-->"):
                index += 1
                continue

            if stripped.startswith("```"):
                info = strip_fence(line)
                buffer: list[str] = []
                index += 1
                while index < len(lines) and not lines[index].strip().startswith("```"):
                    buffer.append(lines[index])
                    index += 1
                if index < len(lines):
                    index += 1
                self.add_code_block(document, "\n".join(buffer), info)
                continue

            if stripped == "$$":
                buffer: list[str] = []
                index += 1
                while index < len(lines) and lines[index].strip() != "$$":
                    buffer.append(lines[index])
                    index += 1
                if index < len(lines):
                    index += 1
                self.add_equation_block(document, "\n".join(buffer).strip())
                continue

            if stripped.startswith("$$") and stripped.endswith("$$") and len(stripped) > 4:
                self.add_equation_block(document, stripped[2:-2].strip())
                index += 1
                continue

            if stripped.startswith("#"):
                level = len(stripped) - len(stripped.lstrip("#"))
                text = stripped[level:].strip()
                self.add_heading(document, level, text)
                index += 1
                continue

            if stripped == "[[TOC]]":
                add_toc_field(document)
                index += 1
                continue

            if TABLE_SEPARATOR_RE.match(lines[index + 1]) if index + 1 < len(lines) else False:
                table_lines = [line, lines[index + 1]]
                index += 2
                while index < len(lines) and "|" in lines[index]:
                    if not lines[index].strip():
                        break
                    table_lines.append(lines[index])
                    index += 1
                self.add_table(document, table_lines)
                continue

            if stripped.lower() == "<table>":
                table_lines = [line]
                index += 1
                while index < len(lines):
                    table_lines.append(lines[index])
                    if lines[index].strip().lower() == "</table>":
                        index += 1
                        break
                    index += 1
                self.add_html_table(document, "\n".join(table_lines))
                continue

            list_match = LIST_ITEM_RE.match(line)
            if list_match:
                index = self.add_list(document, lines, index)
                continue

            if stripped.startswith(">"):
                quote_lines: list[str] = []
                while index < len(lines) and lines[index].lstrip().startswith(">"):
                    inner = lines[index].lstrip()[1:]
                    if inner.startswith(" "):
                        inner = inner[1:]
                    quote_lines.append(inner)
                    index += 1
                if _blockquote_has_block_content(quote_lines):
                    # Embedded items (tables, lists, images, nested paragraphs)
                    # can't live in a single flat paragraph — render them into a
                    # Word "sidebar" container (single-cell table) instead.
                    self.add_blockquote_container(document, quote_lines)
                else:
                    self.add_blockquote(document, " ".join(l.strip() for l in quote_lines).strip())
                continue

            if re.fullmatch(r"[-*_]{3,}", stripped):
                document.add_paragraph("")
                index += 1
                continue

            paragraph_lines = [line.strip()]
            paragraph_lines = [line.rstrip()]
            paragraph_breaks = [line.endswith("  ") or line.endswith("\\")]
            index += 1
            while index < len(lines):
                candidate = lines[index]
                if not candidate.strip():
                    break
                if candidate.strip().startswith(("```", "#", ">")):
                    break
                if LIST_ITEM_RE.match(candidate):
                    break
                if TABLE_SEPARATOR_RE.match(lines[index + 1]) if index + 1 < len(lines) else False:
                    break
                paragraph_lines.append(candidate.rstrip())
                paragraph_breaks.append(candidate.endswith("  ") or candidate.endswith("\\"))
                index += 1
            self.add_paragraph(document, self.combine_paragraph_lines(paragraph_lines, paragraph_breaks))

    @staticmethod
    def combine_paragraph_lines(lines: list[str], breaks: list[bool]) -> str:
        if not lines:
            return ""
        combined = lines[0]
        for index in range(1, len(lines)):
            separator = "\n" if breaks[index - 1] else " "
            combined += separator + lines[index]
        return combined

    @staticmethod
    def apply_template_package(output_docx: Path, template_path: Path) -> None:
        transplant_parts = {
            "word/styles.xml",
            "word/stylesWithEffects.xml",
            "word/numbering.xml",
            "word/fontTable.xml",
            "word/settings.xml",
            "word/webSettings.xml",
            "word/theme/theme1.xml",
        }
        with tempfile.TemporaryDirectory(prefix="build-corpus-template-") as tmp:
            tmp_dir = Path(tmp)
            patched = tmp_dir / output_docx.name
            with ZipFile(output_docx) as out_zip, ZipFile(template_path) as template_zip, ZipFile(patched, "w") as patched_zip:
                template_names = set(template_zip.namelist())
                output_names = set(out_zip.namelist())
                extra_parts, merged_rels, merged_document, merged_content_types = build_style_package_merges(out_zip, template_zip)
                for name in out_zip.namelist():
                    if name == "word/document.xml" and merged_document is not None:
                        patched_zip.writestr(name, merged_document)
                    elif name == "word/_rels/document.xml.rels" and merged_rels is not None:
                        patched_zip.writestr(name, merged_rels)
                    elif name == "[Content_Types].xml" and merged_content_types is not None:
                        patched_zip.writestr(name, merged_content_types)
                    elif name in transplant_parts and name in template_names:
                        patched_zip.writestr(name, template_zip.read(name))
                    else:
                        patched_zip.writestr(name, out_zip.read(name))
                for name in transplant_parts:
                    if name in template_names and name not in output_names:
                        patched_zip.writestr(name, template_zip.read(name))
                for name, data in extra_parts.items():
                    if name not in output_names and name not in transplant_parts:
                        patched_zip.writestr(name, data)
            shutil.move(str(patched), output_docx)

    def add_heading(self, document: Document, level: int, text: str) -> None:
        paragraph = document.add_paragraph(style=f"Heading {min(level, 6)}")
        self.render_inline(paragraph, text)
        self.stats.headings += 1

    def add_paragraph(self, document: Document, text: str) -> None:
        paragraph = document.add_paragraph(style="Normal")
        self.render_inline(paragraph, text)
        self.stats.paragraphs += 1

    def add_code_block(self, document: Document, code: str, info: str) -> None:
        paragraph = document.add_paragraph(style="Normal" if self.use_template_styles else "BuildCorpus Code")
        if info:
            label = paragraph.add_run(f"{info}\n")
            label.bold = True
            label.font.color.rgb = RGBColor(37, 99, 235)
        run = paragraph.add_run(code)
        run.font.name = "Consolas"
        run.font.size = Pt(10)
        set_paragraph_shading(paragraph, "F8FAFC")
        self.stats.code_blocks += 1

    def add_equation_block(self, document: Document, equation: str) -> None:
        paragraph = document.add_paragraph(style="Normal")
        paragraph.paragraph_format.left_indent = Inches(0.3)
        paragraph.paragraph_format.right_indent = Inches(0.3)
        paragraph.paragraph_format.space_before = Pt(4)
        paragraph.paragraph_format.space_after = Pt(8)
        omath = latex_to_omath_para(equation)
        if omath is not None:
            self.add_hidden_math_source(paragraph, equation)
            paragraph._p.append(omath)
            self.stats.equations_omml += 1
        else:
            run = paragraph.add_run(equation)
            run.font.name = "Cambria Math"
            run.font.size = Pt(11)
            self._record_equation_fallback(equation)
        self.stats.equations += 1

    def _add_inline_equation(self, paragraph, latex: str) -> None:
        """Append an inline OMML equation, falling back to Cambria Math text."""
        omath = latex_to_omath(latex)
        if omath is not None:
            self.add_hidden_math_source(paragraph, latex)
            paragraph._p.append(omath)
            self.stats.equations_omml += 1
        else:
            run = paragraph.add_run(latex)
            run.font.name = "Cambria Math"
            run.font.size = Pt(11)
            self._record_equation_fallback(latex)
        self.stats.equations += 1

    @staticmethod
    def add_hidden_math_source(paragraph, latex: str) -> None:
        run = paragraph.add_run(latex)
        run.font.name = "Cambria Math"
        rpr = run._r.get_or_add_rPr()
        rpr.append(OxmlElement("w:vanish"))

    def _record_equation_fallback(self, latex: str) -> None:
        """Record a LaTeX fragment that could not be converted to OMML."""
        self.stats.equations_fell_back += 1
        line = getattr(self, "current_line", 0)
        message = f"Equation kept as text (LaTeX not parsed): {latex[:60]}"
        self.stats.warnings.append(message)
        self.stats.issues.append({
            "type": "equation",
            "line": line,
            "source": latex[:120],
            "reason": "latex-parse-failed",
        })

    def _record_image_failure(self, target: str, reason: str, message: str) -> None:
        """Record an image that could not be embedded, with a specific reason."""
        self.stats.images_failed += 1
        self.stats.warnings.append(f"{message}: {target}")
        self.stats.issues.append({
            "type": "image",
            "line": getattr(self, "current_line", 0),
            "target": target,
            "reason": reason,
        })

    def add_blockquote(self, document: Document, text: str) -> None:
        paragraph = document.add_paragraph(style="Quote" if self.use_template_styles else "BuildCorpus Quote")
        self.render_inline(paragraph, text)
        set_paragraph_border(paragraph, "CBD5E1")
        self.stats.blockquotes += 1

    def add_blockquote_container(self, document: Document, quote_lines: list[str]) -> None:
        """Render a blockquote as a Word "sidebar" — a single-cell table that can
        hold arbitrary block content (tables, lists, images, multiple
        paragraphs, nested quotes).

        The inner Markdown is rendered through the normal block renderer (so every
        block type, including the image layout directive, works verbatim), then
        the produced body elements are relocated into the sidebar cell. Plain
        paragraphs adopt the template's ``Sidebar`` style when a template is in
        play; the cell border + fill supply the callout look either way.
        """
        body = document.element.body
        block_tags = (qn("w:p"), qn("w:tbl"))
        # Capture by POSITION, not id(): lxml re-proxies elements per access, so
        # identity is unstable across iterations. New content always appends at
        # the tail (before the trailing sectPr), so the slice past the prior
        # count is exactly what this blockquote produced — correct under nesting.
        n_before = sum(1 for el in body if el.tag in block_tags)
        self.render_markdown(document, "\n".join(quote_lines))
        moved = [el for el in body if el.tag in block_tags][n_before:]

        table = document.add_table(rows=1, cols=1)
        cell = table.cell(0, 0)
        try:
            cell.width = Emu(self.content_width_emu)
        except Exception:  # pragma: no cover - width is cosmetic, never fatal
            pass
        self._style_sidebar_cell(cell)
        # A table cell must end in a paragraph; python-docx seeds one — keep it as
        # the trailing anchor and insert the moved blocks ahead of it.
        anchor = cell.paragraphs[0]._p
        sidebar_style = "Sidebar" if self.use_template_styles else None
        for element in moved:
            element.getparent().remove(element)
            anchor.addprevious(element)
            if sidebar_style and element.tag == qn("w:p"):
                self._apply_sidebar_paragraph_style(element, sidebar_style)
        self.stats.blockquotes += 1

    def _apply_sidebar_paragraph_style(self, p_element, style_id: str) -> None:
        """Set the Sidebar paragraph style on a plain paragraph, leaving headings,
        list items, and other explicitly-styled paragraphs untouched."""
        p_pr = p_element.find(qn("w:pPr"))
        current = None
        if p_pr is not None:
            existing = p_pr.find(qn("w:pStyle"))
            if existing is not None:
                current = existing.get(qn("w:val"))
        if current not in (None, "Normal", "BuildCorpusQuote", "Quote"):
            return
        if p_pr is None:
            p_pr = OxmlElement("w:pPr")
            p_element.insert(0, p_pr)
        p_style = p_pr.find(qn("w:pStyle"))
        if p_style is None:
            p_style = OxmlElement("w:pStyle")
            p_pr.insert(0, p_style)
        p_style.set(qn("w:val"), style_id)

    def _style_sidebar_cell(self, cell) -> None:
        """Give the sidebar cell a left accent border + light fill (the callout
        look), independent of whether a template supplies the Sidebar style."""
        tc_pr = cell._tc.get_or_add_tcPr()
        shading = OxmlElement("w:shd")
        shading.set(qn("w:val"), "clear")
        shading.set(qn("w:color"), "auto")
        shading.set(qn("w:fill"), "F1F5F9")
        tc_pr.append(shading)
        borders = OxmlElement("w:tcBorders")
        for edge, size in (("left", "18"), ("top", "4"), ("bottom", "4"), ("right", "4")):
            border = OxmlElement(f"w:{edge}")
            border.set(qn("w:val"), "single")
            border.set(qn("w:sz"), size)
            border.set(qn("w:space"), "0")
            border.set(qn("w:color"), "CBD5E1" if edge != "left" else "94A3B8")
            borders.append(border)
        tc_pr.append(borders)

    def add_list(self, document: Document, lines: list[str], start: int) -> int:
        index = start
        while index < len(lines):
            match = LIST_ITEM_RE.match(lines[index])
            if not match:
                break
            indent, marker, body = match.groups()
            ordered = marker.endswith(".")
            body_lines = [body]
            lookahead = index + 1
            while lookahead < len(lines):
                candidate = lines[lookahead]
                stripped = candidate.strip()
                if not stripped:
                    break
                if candidate.strip().startswith(("```", "#", ">")):
                    break
                if LIST_ITEM_RE.match(candidate):
                    break
                if TABLE_SEPARATOR_RE.match(lines[lookahead + 1]) if lookahead + 1 < len(lines) else False:
                    break
                if not candidate[:1].isspace():
                    break
                body_lines.append(candidate.rstrip())
                lookahead += 1
            style_name = self.list_style_name(document, ordered, indent)
            paragraph = document.add_paragraph(style=style_name)
            if style_name in {"List Bullet", "List Number"}:
                paragraph.paragraph_format.left_indent = Inches(0.25 + (len(indent.replace("\t", "  ")) // 2) * 0.18)
            self.render_inline(paragraph, "\n".join(body_lines))
            self.stats.lists += 1
            index = lookahead
        return index

    def list_style_name(self, document: Document, ordered: bool, indent: str) -> str:
        level = min(3, max(1, (len(indent.replace("\t", "  ")) // 2) + 1))
        base = "List Number" if ordered else "List Bullet"
        candidate = base if level == 1 else f"{base} {level}"
        return candidate if candidate in document.styles else base

    def pop_pending_table_style(self) -> str | None:
        style = self.pending_table_style
        self.pending_table_style = None
        return style

    def pop_pending_table_meta(self) -> dict | None:
        meta = self.pending_table_meta
        self.pending_table_meta = None
        return meta

    def add_table(self, document: Document, table_lines: list[str]) -> None:
        separator = next((l for l in table_lines if TABLE_SEPARATOR_RE.match(l)), None)
        column_alignments = parse_column_alignments(separator) if separator else None
        rows = [split_table_row(line) for line in table_lines if not TABLE_SEPARATOR_RE.match(line)]
        self.add_table_rows(
            document,
            rows,
            table_style=self.pop_pending_table_style(),
            table_meta=self.pop_pending_table_meta(),
            column_alignments=column_alignments,
        )

    def add_html_table(self, document: Document, table_markup: str) -> None:
        self.add_table_rows(
            document,
            parse_html_table(table_markup),
            table_style=self.pop_pending_table_style(),
            table_meta=self.pop_pending_table_meta(),
        )

    def resolve_table_meta(self, table_style: str | None, table_meta: dict | None) -> dict | None:
        """Resolve the effective table-meta for a table.

        Precedence (DD-6): explicit build-corpus:table meta > explicit
        table-style comment > house-style default > plain.

        The house-style default applies ONLY to an unstyled table (no meta, no
        table-style comment) AND only when frontmatter declared a ``house_style``.
        With no house_style the table is left plain (sanitizer-only contract).
        """
        if table_meta is not None:
            return table_meta
        if table_style is not None:
            return {"style": table_style, "cols": [], "borders": None, "shade": None, "pad": None}
        return house_table_default(self.house_style)

    def add_table_rows(
        self,
        document: Document,
        rows: list[list[str]],
        table_style: str | None = None,
        table_meta: dict | None = None,
        column_alignments: list[str | None] | None = None,
    ) -> None:
        if not rows:
            return
        width = max(len(row) for row in rows)
        table = document.add_table(rows=len(rows), cols=width)

        effective_meta = self.resolve_table_meta(table_style, table_meta)
        has_explicit_style = bool(effective_meta and effective_meta.get("style"))
        if not has_explicit_style:
            # Default base style when no explicit style is supplied (unchanged
            # plain-table behavior). Border/shade meta still layers on top.
            table.style = "Light List Accent 1" if "Light List Accent 1" in document.styles else "Table Grid"

        for row_index, row in enumerate(rows):
            for col_index in range(width):
                value = row[col_index] if col_index < len(row) else ""
                self.render_table_cell(table.cell(row_index, col_index), value)

        if effective_meta:
            apply_table_meta(table, effective_meta, document=document)

        # Fix E: honor the Markdown column-alignment separator. Writing an
        # explicit <w:jc> here also signals the house-style classifier (Fix D) to
        # leave these columns alone — author intent beats content inference.
        if column_alignments:
            for row_index in range(len(rows)):
                for col_index in range(width):
                    align = (column_alignments[col_index]
                             if col_index < len(column_alignments) else None)
                    if not align:
                        continue
                    for paragraph in table.cell(row_index, col_index).paragraphs:
                        _set_paragraph_alignment(paragraph, align)

        self.stats.tables += 1

    def render_table_cell(self, cell, text: str) -> None:
        lines = set_cell_text(cell, text)
        if not lines:
            cell.text = ""
            return
        first = cell.paragraphs[0]
        first.text = ""
        self.render_inline(first, lines[0])
        for line in lines[1:]:
            paragraph = cell.add_paragraph("")
            self.render_inline(paragraph, line)

    def render_inline(self, paragraph, text: str) -> None:
        for kind, value in scan_inline_tokens(text):
            if kind == "token":
                self.render_inline_token(paragraph, value)
            else:
                self.render_plain_text(paragraph, value)

    def render_plain_text(self, paragraph, text: str) -> None:
        cursor = 0
        for match in BARE_URL_RE.finditer(text):
            if match.start() > cursor:
                append_text_with_breaks(paragraph, text[cursor:match.start()])
            url = match.group(0)
            normalized_url, trailing = split_trailing_url_punctuation(url)
            if normalized_url:
                add_hyperlink(paragraph, normalized_url, normalized_url)
            if trailing:
                append_text_with_breaks(paragraph, trailing)
            cursor = match.end()
        if cursor < len(text):
            append_text_with_breaks(paragraph, text[cursor:])

    def _compute_layout_metrics(self, document: Document) -> None:
        """Cache page + text-column width (EMU) for image width fractions.

        Mirrors the margin precedence apply_page_margins uses for the two sides
        that matter here: frontmatter/config ``page.<side>_in`` > template
        ``<w:pgMar>`` > the live section margin (frontmatter ``margins.<side>``
        and the modern-style defaults are already on the section by now).
        """
        section = document.sections[0]
        page_w = int(section.page_width) if section.page_width else int(Inches(8.5))
        left = int(section.left_margin) if section.left_margin else int(Inches(1))
        right = int(section.right_margin) if section.right_margin else int(Inches(1))
        if self.template_path:
            template_margins = self._read_template_pgmar(self.template_path)
            if template_margins:
                if "left" in template_margins:
                    left = int(Twips(int(template_margins["left"])))
                if "right" in template_margins:
                    right = int(Twips(int(template_margins["right"])))
        for side, is_left in (("left", True), ("right", False)):
            override = self.house_style_values.get(f"page.{side}_in")
            if override in (None, ""):
                continue
            try:
                emu = int(Inches(float(override)))
            except (TypeError, ValueError):
                continue
            if is_left:
                left = emu
            else:
                right = emu
        self.page_width_emu = page_w
        self.content_width_emu = max(page_w - left - right, int(Inches(1)))

    def _resolve_image_width_emu(self, attrs: dict) -> int | None:
        """Resolve a width directive to EMU, clamped to the text column.

        Returns None when no width was declared (caller keeps the legacy
        default). ``bleed`` is the one spec that may exceed the text column.
        """
        spec = attrs.get("width")
        if not spec:
            return None
        spec = spec.strip().lower()
        cw = self.content_width_emu
        if spec in ("bleed", "full-bleed"):
            return self.page_width_emu
        pct = re.fullmatch(r"(\d+(?:\.\d+)?)%", spec)
        if pct:
            return min(int(cw * float(pct.group(1)) / 100), cw)
        frac = re.fullmatch(r"(\d+)\s*/\s*(\d+)", spec)
        if frac and int(frac.group(2)) != 0:
            return min(int(cw * int(frac.group(1)) / int(frac.group(2))), cw)
        if spec in _WIDTH_KEYWORDS:
            return min(int(cw * _WIDTH_KEYWORDS[spec]), cw)
        unit = re.fullmatch(r'(\d+(?:\.\d+)?)\s*(in|inch|inches|cm|mm|")', spec)
        if unit:
            return min(int(float(unit.group(1)) * _WIDTH_UNIT_EMU[unit.group(2)]), cw)
        return None

    def _apply_image_layout(self, run, paragraph, attrs: dict) -> None:
        """Apply alignment + wrap to a freshly inserted picture run.

        square/tight with a left/right side become a floating (anchored)
        drawing that text wraps around. Everything else stays inline and is
        positioned with paragraph justification.
        """
        align = attrs.get("align")
        wrap = attrs.get("wrap")
        if wrap in ("square", "tight", "float") and align in ("left", "right"):
            self._floatify_image(run, align, wrap)
            return
        align_map = {
            "left": WD_ALIGN_PARAGRAPH.LEFT,
            "center": WD_ALIGN_PARAGRAPH.CENTER,
            "right": WD_ALIGN_PARAGRAPH.RIGHT,
        }
        if align in align_map:
            paragraph.alignment = align_map[align]

    def _floatify_image(self, run, align: str, wrap: str) -> None:
        """Rewrite an inline ``<wp:inline>`` drawing as a floating ``<wp:anchor>``.

        python-docx can only emit inline pictures, so we reuse its image part +
        relationship plumbing and swap just the drawing wrapper for an anchor
        carrying a wrap element and a column-relative horizontal position.
        """
        drawing = run._r.find(qn("w:drawing"))
        if drawing is None:
            return
        inline = drawing.find(qn("wp:inline"))
        if inline is None:
            return
        extent = inline.find(qn("wp:extent"))
        graphic = inline.find(qn("a:graphic"))
        doc_pr = inline.find(qn("wp:docPr"))
        if extent is None or graphic is None:
            return
        cx, cy = extent.get("cx"), extent.get("cy")
        if not cx or not cy:
            return

        anchor = OxmlElement("wp:anchor")
        for name, value in (
            ("distT", "0"), ("distB", "0"), ("distL", "114300"), ("distR", "114300"),
            ("simplePos", "0"), ("relativeHeight", "2"), ("behindDoc", "0"),
            ("locked", "0"), ("layoutInCell", "1"), ("allowOverlap", "1"),
        ):
            anchor.set(name, value)

        simple_pos = OxmlElement("wp:simplePos")
        simple_pos.set("x", "0")
        simple_pos.set("y", "0")
        anchor.append(simple_pos)

        pos_h = OxmlElement("wp:positionH")
        pos_h.set("relativeFrom", "column")
        h_align = OxmlElement("wp:align")
        h_align.text = "right" if align == "right" else "left"
        pos_h.append(h_align)
        anchor.append(pos_h)

        pos_v = OxmlElement("wp:positionV")
        pos_v.set("relativeFrom", "paragraph")
        v_offset = OxmlElement("wp:posOffset")
        v_offset.text = "0"
        pos_v.append(v_offset)
        anchor.append(pos_v)

        new_extent = OxmlElement("wp:extent")
        new_extent.set("cx", cx)
        new_extent.set("cy", cy)
        anchor.append(new_extent)

        effect_extent = OxmlElement("wp:effectExtent")
        for edge in ("l", "t", "r", "b"):
            effect_extent.set(edge, "0")
        anchor.append(effect_extent)

        # Text wraps on the side opposite the image.
        wrap_text = "left" if align == "right" else "right"
        if wrap == "tight":
            wrap_el = OxmlElement("wp:wrapTight")
            wrap_el.set("wrapText", wrap_text)
            polygon = OxmlElement("wp:wrapPolygon")
            polygon.set("edited", "0")
            start = OxmlElement("wp:start")
            start.set("x", "0")
            start.set("y", "0")
            polygon.append(start)
            for x, y in (("0", cy), (cx, cy), (cx, "0"), ("0", "0")):
                line_to = OxmlElement("wp:lineTo")
                line_to.set("x", x)
                line_to.set("y", y)
                polygon.append(line_to)
            wrap_el.append(polygon)
        else:
            wrap_el = OxmlElement("wp:wrapSquare")
            wrap_el.set("wrapText", wrap_text)
        anchor.append(wrap_el)

        anchor.append(copy.deepcopy(doc_pr) if doc_pr is not None
                      else OxmlElement("wp:docPr"))
        anchor.append(OxmlElement("wp:cNvGraphicFramePr"))
        anchor.append(copy.deepcopy(graphic))

        drawing.replace(inline, anchor)

    def render_inline_token(self, paragraph, token: str) -> None:
        if token.startswith("!["):
            base, attrs = split_image_attrs(token)
            alt, target = markdown_link_parts(base)
            width_emu = self._resolve_image_width_emu(attrs)
            pic_width = Emu(width_emu) if width_emu else Inches(5.8)
            image_path = (self.input_path.parent / target).resolve()
            ext = image_path.suffix.lower()
            if target.startswith(("http://", "https://", "data:")):
                paragraph.add_run(f"[image: {alt or target}]")
                self._record_image_failure(target, "skipped-remote",
                                           "Skipped non-local image target")
                return
            if ext == ".svg" or (ext in UNRENDERABLE_IMAGE_EXTS and image_looks_like_svg(image_path)):
                paragraph.add_run(f"[unsupported image: {target}]")
                self._record_image_failure(target, "svg-needs-rasterization",
                                           "SVG cannot be embedded by python-docx; rasterize to PNG via the render pipeline")
                return
            if ext in UNRENDERABLE_IMAGE_EXTS:
                # HTML/JSX can never be embedded as a picture — flag for the
                # external render pipeline. We deliberately do NOT rasterize here.
                paragraph.add_run(f"[unsupported image: {target}]")
                self._record_image_failure(target, "unsupported-format",
                                           f"Image format cannot be embedded ({ext}); route to render pipeline")
                return
            if not image_path.exists():
                paragraph.add_run(f"[missing image: {target}]")
                self._record_image_failure(target, "missing-file", "Missing image asset")
                return
            run = paragraph.add_run()
            try:
                run.add_picture(str(image_path), width=pic_width)
                set_picture_metadata(run, target)
                self._apply_image_layout(run, paragraph, attrs)
                self.stats.images += 1
            except UnrecognizedImageError:
                converted = convert_windows_metafile_to_png(image_path)
                if converted is not None:
                    run.add_picture(str(converted), width=pic_width)
                    set_picture_metadata(run, target)
                    self._apply_image_layout(run, paragraph, attrs)
                    self.stats.images += 1
                    self.stats.warnings.append(f"Converted unsupported image to PNG: {target}")
                elif image_looks_like_svg(image_path):
                    # SVG content mislabeled with a raster extension (e.g. .png that
                    # is really SVG+PNG-fallback the extractor flattened wrong).
                    paragraph.add_run(f"[unsupported image: {target}]")
                    self._record_image_failure(target, "mislabeled-svg",
                                               f"{target} is SVG content with a {ext or 'raster'} extension; rasterize to PNG and repoint")
                elif ext in METAFILE_IMAGE_EXTS:
                    paragraph.add_run(f"[unsupported image: {target}]")
                    self._record_image_failure(target, "unsupported-on-platform",
                                               f"{ext} needs metafile->PNG conversion (install LibreOffice / run on Windows)")
                else:
                    paragraph.add_run(f"[unsupported image: {target}]")
                    self._record_image_failure(target, "unsupported-format",
                                               "Image format not recognized by python-docx")
            return

        if token.startswith("["):
            label, target = markdown_link_parts(token)
            hyperlink = add_hyperlink(paragraph, "", target)
            self.render_hyperlink_label(hyperlink, label)
            self.stats.links += 1
            return

        if token.startswith("`"):
            run = paragraph.add_run(token[1:-1])
            run.font.name = "Consolas"
            run.font.size = Pt(10)
            return

        if token.startswith("$$") and token.endswith("$$"):
            self._add_inline_equation(paragraph, token[2:-2])
            return

        if token.startswith("$") and token.endswith("$"):
            self._add_inline_equation(paragraph, token[1:-1])
            return

        if token.startswith("***") and token.endswith("***"):
            run = paragraph.add_run(token[3:-3])
            run.bold = True
            run.italic = True
            return

        if token.startswith("**") and token.endswith("**"):
            run = paragraph.add_run(token[2:-2])
            run.bold = True
            return

        if token.startswith("*") and token.endswith("*"):
            run = paragraph.add_run(token[1:-1])
            run.italic = True
            return

        paragraph.add_run(token)

    def render_hyperlink_label(self, hyperlink, text: str) -> None:
        for kind, value in scan_inline_tokens(text):
            if kind == "token":
                self.render_hyperlink_token(hyperlink, value)
            else:
                append_hyperlink_run(hyperlink, unescape_markdown_text(value))

    def render_hyperlink_token(self, hyperlink, token: str) -> None:
        if token.startswith("`") and token.endswith("`"):
            append_hyperlink_run(hyperlink, unescape_markdown_text(token[1:-1]), code=True)
            return
        if token.startswith("***") and token.endswith("***"):
            append_hyperlink_run(hyperlink, unescape_markdown_text(token[3:-3]), bold=True, italic=True)
            return
        if token.startswith("**") and token.endswith("**"):
            append_hyperlink_run(hyperlink, unescape_markdown_text(token[2:-2]), bold=True)
            return
        if token.startswith("*") and token.endswith("*"):
            append_hyperlink_run(hyperlink, unescape_markdown_text(token[1:-1]), italic=True)
            return
        append_hyperlink_run(hyperlink, unescape_markdown_text(token))


def export_markdown_to_docx(
    input_path: Path,
    output_root: Path,
    out_same_dir: bool,
    template_path: Path | None = None,
    house_style_values: dict | None = None,
) -> dict:
    if out_same_dir:
        output_dir = input_path.parent
        output_docx = input_path.with_suffix(".docx")
        report_path = input_path.with_name(f"{input_path.stem}.export-report.json")
    else:
        output_dir = output_root / input_path.stem
        output_docx = None
        report_path = None

    exporter = MarkdownToDocxExporter(
        input_path=input_path,
        output_dir=output_dir,
        output_docx=output_docx,
        report_path=report_path,
        template_path=template_path,
        house_style_values=house_style_values,
    )
    return exporter.export()
