"""OOXML post-processing: a correctness sanitizer that runs on the finished
.docx package and guarantees Word-valid output regardless of template defects.

Background
----------
``apply_template_package`` transplants ``styles.xml``, ``fontTable.xml`` and
``settings.xml`` *verbatim* from the Word/style-package template. Real-world
templates routinely carry defects that LibreOffice tolerates but Word rejects
as "unreadable content":

1. **Floating-point measurement attributes** -- e.g. ``w:w="108.0"`` in a table
   style's ``tblCellMar``. ``w:w`` (and friends) are xsd integers; the decimal
   is invalid and Word repairs the file, reporting the error in the *Styles*
   part. This was the root cause of repeated FuturVille ASP corruption.
2. **Schema child-order violations** -- ``tblPr``/``tcPr``/``tcMar``/borders
   children written out of the order the schema mandates.
3. **Dead font embeds** -- ``<w:embedRegular/>`` etc. in ``fontTable.xml`` with
   no accompanying font binaries / rels, plus ``<w:embedTrueTypeFonts/>`` in
   ``settings.xml`` claiming embedding that does not exist.

``sanitize_package`` neutralizes all three on the packaged output. ``audit_package``
performs the same checks read-only so the exporter's validation gate can *fail
the build* when a defect is present rather than shipping a file Word will reject.

Both functions are dependency-free (stdlib only) and conservative: they only
rewrite the specific defects above, leaving all other bytes untouched.
"""

from __future__ import annotations

import re
import shutil
import tempfile
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile

W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"

# --- integer-typed measurement attributes (ST_* integer / TwipsMeasure / etc.) ---
# These must never carry a decimal point. Names are matched in the ``w:`` namespace.
_INT_ATTRS = (
    "w", "sz", "szCs", "after", "before", "line", "lineRule",
    "firstLine", "hanging", "left", "right", "start", "end",
    "top", "bottom", "space", "beforeLines", "afterLines",
    "tblInd", "gridCol", "trHeight",
)
_FLOAT_ATTR_RE = re.compile(
    r'(w:(?:' + "|".join(_INT_ATTRS) + r')=")(\d+)\.\d+(")'
)

# --- schema child-order tables (ECMA-376) ---
_TBLPR_ORDER = [
    "tblStyle", "tblpPr", "tblOverlap", "bidiVisual", "tblStyleRowBandSize",
    "tblStyleColBandSize", "tblW", "tblJc", "tblCellSpacing", "tblInd",
    "tblBorders", "shd", "tblLayout", "tblCellMar", "tblLook", "tblCaption",
    "tblDescription", "tblPrChange",
]
_TCPR_ORDER = [
    "cnfStyle", "tcW", "gridSpan", "hMerge", "vMerge", "tcBorders", "shd",
    "noWrap", "tcMar", "textDirection", "tcFit", "vAlign", "hideMark",
    "headers", "cellIns", "cellDel", "cellMerge", "tcPrChange",
]
# tcMar / tblCellMar / tblBorders / tcBorders share an edge-ordered child set
_EDGE_ORDER = [
    "top", "left", "start", "bottom", "right", "end",
    "insideH", "insideV", "tl2br", "tr2bl",
]
# CT_PPr child order (ECMA-376). Covers paragraph and paragraph-mark properties.
_PPR_ORDER = [
    "pStyle", "keepNext", "keepLines", "pageBreakBefore", "framePr",
    "widowControl", "numPr", "suppressLineNumbers", "pBdr", "shd", "tabs",
    "suppressAutoHyphens", "kinsoku", "wordWrap", "overflowPunct",
    "topLinePunct", "autoSpaceDE", "autoSpaceDN", "bidi", "adjustRightInd",
    "snapToGrid", "spacing", "ind", "contextualSpacing", "mirrorIndents",
    "suppressOverlap", "jc", "textDirection", "textAlignment",
    "textboxTightWrap", "outlineLvl", "divId", "cnfStyle", "rPr", "sectPr",
    "pPrChange",
]
# CT_RPr child order (ECMA-376). Run properties (and paragraph-mark rPr).
_RPR_ORDER = [
    "rStyle", "rFonts", "b", "bCs", "i", "iCs", "caps", "smallCaps", "strike",
    "dstrike", "outline", "shadow", "emboss", "imprint", "noProof",
    "snapToGrid", "vanish", "webHidden", "color", "spacing", "w", "kern",
    "position", "sz", "szCs", "highlight", "u", "effect", "bdr", "shd",
    "fitText", "vertAlign", "rtl", "cs", "em", "lang", "eastAsianLayout",
    "specVanish", "oMath", "rPrChange",
]
_ORDER_BY_PARENT = {
    "tblPr": _TBLPR_ORDER,
    "tcPr": _TCPR_ORDER,
    "tcMar": _EDGE_ORDER,
    "tblCellMar": _EDGE_ORDER,
    "tblBorders": _EDGE_ORDER,
    "tcBorders": _EDGE_ORDER,
    "pPr": _PPR_ORDER,
    "rPr": _RPR_ORDER,
}
# start/end are the strict-OOXML edge names; some writers emit left/right inside
# borders/margins. Word accepts both, but we normalize ``start->left`` /
# ``end->right`` only where it does NOT change geometry (margins/borders edges).

# The float / child-order / mc:Ignorable defects all live in the WordprocessingML
# parts under ``word/``. ``audit_package`` and ``sanitize_package`` MUST scan and
# repair the *same* set, or a defect (e.g. in footnotes.xml / comments.xml /
# endnotes.xml) gets flagged by the audit but never repaired, forcing
# ``fidelity_ok=False`` with no remediation. ``_audited_xml_parts`` is the single
# source of truth for that symmetric set.
_PARTS_TO_SANITIZE = (
    "word/styles.xml", "word/stylesWithEffects.xml", "word/document.xml",
    "word/numbering.xml", "word/settings.xml",
)


def _audited_xml_parts(names) -> list[str]:
    """The WordprocessingML parts subject to float/child-order/ignorable repair.

    Any ``word/*.xml`` part (document, styles, settings, numbering, headers,
    footers, footnotes, endnotes, comments, ...) can carry the integer-attr,
    child-order, or undeclared-Ignorable-namespace defects. Both the audit and
    the sanitizer iterate this exact set so coverage stays symmetric.
    """
    return sorted(
        n for n in names
        if n.startswith("word/") and n.endswith(".xml")
    )


def _local(tag) -> str:
    if not isinstance(tag, str):
        return ""
    return tag.rsplit("}", 1)[-1] if "}" in tag else tag


def _coerce_int_attrs(xml: str) -> tuple[str, int]:
    """Convert decimal measurement attribute values to integers (truncate)."""
    fixed = _FLOAT_ATTR_RE.sub(r"\1\2\3", xml)
    n = len(_FLOAT_ATTR_RE.findall(xml))
    return fixed, n


def _root_ns_decls(text: str) -> dict:
    """Extract ``xmlns:prefix="uri"`` declarations from a part's root element."""
    i = text.find("?>")
    start = i + 2 if i != -1 else 0
    lt = text.find("<", start)
    gt = text.find(">", lt)
    head = text[lt:gt + 1] if lt != -1 and gt != -1 else ""
    return dict(re.findall(r'xmlns:([A-Za-z0-9_]+)="([^"]+)"', head))


def _normalize_child_order(data: bytes) -> tuple[bytes, int]:
    """Reorder tblPr/tcPr/tcMar/border/pPr/rPr children into schema sequence and
    backfill required attributes (pgMar/@gutter).

    Uses lxml so namespace prefixes are preserved. lxml *prunes* root namespace
    declarations it deems unused (e.g. ``w14``/``wp14`` that are only referenced
    by the ``mc:Ignorable`` string attribute, which it cannot see as usage), so
    we re-inject any pruned declarations after serialization.
    """
    from lxml import etree

    try:
        root = etree.fromstring(data)
    except etree.XMLSyntaxError:
        return data, 0
    changed = 0
    gutter_q = f"{{{W}}}gutter"
    for el in root.iter():
        name = _local(el.tag)
        order = _ORDER_BY_PARENT.get(name)
        if order:
            idx = {n: i for i, n in enumerate(order)}
            kids = list(el)
            ranked = sorted(kids, key=lambda e: idx.get(_local(e.tag), 10 ** 6))
            if [id(k) for k in ranked] != [id(k) for k in kids]:
                for k in kids:
                    el.remove(k)
                for k in ranked:
                    el.append(k)
                changed += 1
        elif name == "pgMar" and el.get(gutter_q) is None:
            # w:gutter is required by the strict CT_PageMar schema; Word omits it
            # in transitional output. Backfill a zero gutter so the part validates.
            el.set(gutter_q, "0")
            changed += 1
    if not changed:
        return data, 0
    out = etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True)
    orig_ns = _root_ns_decls(data.decode("utf-8", "replace"))
    new_text = out.decode("utf-8")
    new_ns = _root_ns_decls(new_text)
    missing = {p: u for p, u in orig_ns.items() if p not in new_ns}
    if missing:
        i = new_text.find("?>")
        start = i + 2 if i != -1 else 0
        gt = new_text.find(">", new_text.find("<", start))
        inject = "".join(f' xmlns:{p}="{u}"' for p, u in missing.items())
        new_text = new_text[:gt] + inject + new_text[gt:]
        out = new_text.encode("utf-8")
    return out, changed


def _strip_dead_font_embeds(ft_xml: str | None, settings_xml: str | None) -> tuple[str | None, str | None, int]:
    """Remove embedRegular/Bold/etc. from fontTable and embedTrueTypeFonts from settings."""
    n = 0
    if ft_xml is not None:
        before = ft_xml
        ft_xml = re.sub(r"<w:embed(?:Regular|Bold|Italic|BoldItalic)\b[^>]*/>", "", ft_xml)
        n += len(re.findall(r"<w:embed(?:Regular|Bold|Italic|BoldItalic)\b", before))
    if settings_xml is not None:
        before = settings_xml
        settings_xml = re.sub(r"<w:embed(?:TrueTypeFonts|SystemFonts)\b[^>]*/>", "", settings_xml)
        settings_xml = re.sub(r"<w:saveSubsetFonts\b[^>]*/>", "", settings_xml)
        n += len(re.findall(r"<w:embed(?:TrueTypeFonts|SystemFonts)\b", before))
    return ft_xml, settings_xml, n


# Known Markup-Compatibility extension namespaces. python-docx emits
# ``mc:Ignorable="w14 wp14"`` without declaring those prefixes on the root; Word
# tolerates it but the strict validator (and some readers) reject an Ignorable
# reference to an undeclared namespace. We declare any referenced prefix we know.
_MCE_NS = {
    "w14": "http://schemas.microsoft.com/office/word/2010/wordml",
    "w15": "http://schemas.microsoft.com/office/word/2012/wordml",
    "w16": "http://schemas.microsoft.com/office/word/2018/wordml",
    "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid",
    "w16se": "http://schemas.microsoft.com/office/word/2015/wordml/symex",
    "w16du": "http://schemas.microsoft.com/office/word/2023/wordml/word16du",
    "w16sdtdh": "http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash",
    "wp14": "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing",
    "wne": "http://schemas.microsoft.com/office/word/2006/wordml",
    "cx": "http://schemas.microsoft.com/office/drawing/2014/chartex",
}


def _fix_ignorable(text: str) -> tuple[str, int]:
    """Declare any namespace referenced by ``mc:Ignorable`` but missing on root."""
    m = re.search(r'mc:Ignorable="([^"]*)"', text)
    if not m:
        return text, 0
    prefixes = m.group(1).split()
    if not prefixes:
        return text, 0
    declared = set(_root_ns_decls(text).keys())
    inject = [(p, _MCE_NS[p]) for p in prefixes if p not in declared and p in _MCE_NS]
    if not inject:
        return text, 0
    i = text.find("?>")
    start = i + 2 if i != -1 else 0
    lt = text.find("<", start)
    gt = text.find(">", lt)
    add = "".join(f' xmlns:{p}="{u}"' for p, u in inject)
    return text[:gt] + add + text[gt:], len(inject)


def _repack(src: Path, replacements: dict[str, bytes]) -> None:
    """Rewrite a docx zip with replaced parts, [Content_Types].xml first."""
    with ZipFile(src) as zin:
        names = zin.namelist()
        order = ["[Content_Types].xml"] + [n for n in names if n != "[Content_Types].xml"]
        with tempfile.TemporaryDirectory(prefix="bc-sanitize-") as tmp:
            out = Path(tmp) / src.name
            with ZipFile(out, "w", ZIP_DEFLATED) as zout:
                for name in order:
                    zout.writestr(name, replacements.get(name, zin.read(name)))
            shutil.move(str(out), src)


def sanitize_package(path: str | Path) -> dict:
    """Make a packaged .docx Word-valid in place.

    Returns a report dict: ``{floats_fixed, order_normalized, font_embeds_stripped}``.
    """
    path = Path(path)
    with ZipFile(path) as z:
        names = set(z.namelist())
        blobs = {n: z.read(n) for n in names}

    replacements: dict[str, bytes] = {}
    floats = order = embeds = namespaces = 0

    for part in _audited_xml_parts(blobs):
        if part not in blobs:
            continue
        xml = blobs[part].decode("utf-8", "replace")
        xml, nf = _coerce_int_attrs(xml)
        floats += nf
        xml, ni = _fix_ignorable(xml)
        namespaces += ni
        data = xml.encode("utf-8")
        data, no = _normalize_child_order(data)
        order += no
        if nf or no or ni:
            replacements[part] = data
            blobs[part] = data

    ft = blobs.get("word/fontTable.xml")
    st = blobs.get("word/settings.xml")
    ft_xml = ft.decode("utf-8", "replace") if ft is not None else None
    st_xml = (replacements.get("word/settings.xml", st).decode("utf-8", "replace")
              if st is not None else None)
    ft2, st2, ne = _strip_dead_font_embeds(ft_xml, st_xml)
    embeds += ne
    if ne:
        if ft is not None and ft2 is not None:
            replacements["word/fontTable.xml"] = ft2.encode("utf-8")
        if st is not None and st2 is not None:
            replacements["word/settings.xml"] = st2.encode("utf-8")

    if replacements:
        _repack(path, replacements)

    return {
        "floats_fixed": floats,
        "order_normalized": order,
        "font_embeds_stripped": embeds,
        "namespaces_declared": namespaces,
    }


def audit_package(path: str | Path) -> list[dict]:
    """Read-only check for the defects ``sanitize_package`` repairs.

    Returns a list of issue dicts (empty == clean). Suitable for a build gate.
    """
    path = Path(path)
    issues: list[dict] = []
    with ZipFile(path) as z:
        names = z.namelist()
        # Float / child-order / Ignorable defects are scanned over the SAME set
        # the sanitizer repairs (``_audited_xml_parts``) so every flagged defect
        # has a remediation path. Scanning a wider set than sanitize repairs is
        # exactly the asymmetry that forced fidelity_ok=False with no fix.
        audited = _audited_xml_parts(names)
        for part in audited:
            xml = z.read(part).decode("utf-8", "replace")
            nf = len(_FLOAT_ATTR_RE.findall(xml))
            if nf:
                issues.append({
                    "type": "ooxml", "part": part,
                    "reason": "float-measurement-attr", "count": nf,
                })
            data = z.read(part)
            _, moved = _normalize_child_order(data)
            if moved:
                issues.append({
                    "type": "ooxml", "part": part,
                    "reason": "child-order-violation", "count": moved,
                })
        if "word/fontTable.xml" in names:
            ft = z.read("word/fontTable.xml").decode("utf-8", "replace")
            ne = len(re.findall(r"<w:embed(?:Regular|Bold|Italic|BoldItalic)\b", ft))
            if ne:
                issues.append({
                    "type": "ooxml", "part": "word/fontTable.xml",
                    "reason": "dead-font-embed", "count": ne,
                })
        if "word/settings.xml" in names:
            st = z.read("word/settings.xml").decode("utf-8", "replace")
            if "embedTrueTypeFonts" in st:
                issues.append({
                    "type": "ooxml", "part": "word/settings.xml",
                    "reason": "embedTrueTypeFonts-without-fonts", "count": 1,
                })
        for part in audited:
            txt = z.read(part).decode("utf-8", "replace")
            m = re.search(r'mc:Ignorable="([^"]*)"', txt)
            if not m:
                continue
            declared = set(_root_ns_decls(txt).keys())
            undeclared = [p for p in m.group(1).split() if p and p not in declared]
            if undeclared:
                issues.append({
                    "type": "ooxml", "part": part,
                    "reason": "ignorable-undeclared-namespace",
                    "count": len(undeclared), "prefixes": undeclared,
                })
    return issues
