#!/usr/bin/env python3
"""Apply and verify journal-required formatting and accessibility in rendered DOCX files."""

from __future__ import annotations

import argparse
from dataclasses import dataclass
import io
import os
import posixpath
import re
import stat
import tempfile
import unicodedata
import zipfile
from pathlib import Path, PurePosixPath
import xml.etree.ElementTree as ET

ROOT = Path(__file__).resolve().parents[1]
FIXED_ZIP_TIME = (1980, 1, 1, 0, 0, 0)

W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
WP = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
PIC = "http://schemas.openxmlformats.org/drawingml/2006/picture"
M = "http://schemas.openxmlformats.org/officeDocument/2006/math"
PKG_REL = "http://schemas.openxmlformats.org/package/2006/relationships"
CONTENT_TYPES = "http://schemas.openxmlformats.org/package/2006/content-types"
FOOTER_RELATIONSHIP = f"{R}/footer"
FOOTER_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"
XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace"

for prefix, namespace in (
    ("w", W),
    ("r", R),
    ("wp", WP),
    ("pic", PIC),
    ("a", "http://schemas.openxmlformats.org/drawingml/2006/main"),
    ("m", "http://schemas.openxmlformats.org/officeDocument/2006/math"),
    ("mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"),
    ("w14", "http://schemas.microsoft.com/office/word/2010/wordml"),
    ("w15", "http://schemas.microsoft.com/office/word/2012/wordml"),
    ("wp14", "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"),
    ("v", "urn:schemas-microsoft-com:vml"),
    ("o", "urn:schemas-microsoft-com:office:office"),
):
    ET.register_namespace(prefix, namespace)

ALT_PATTERN = re.compile(r"!\[[^\]]*\]\([^\n)]+\)\{[^\n}]*\bfig-alt=\"([^\"]+)\"[^\n}]*\}")

DOCUMENTS = {
    "main": {
        "source": ROOT / "manuscript" / "manuscript.qmd",
        "docx": ROOT / "manuscript" / "manuscript.docx",
        "figure_prefix": "Figure",
        "expected_figures": 4,
        "expected_equations": 8,
        "equation_markers": (
            ("θ", "P", "AA", "BB"),
            ("d", "475"),
            ("logit", "β"),
            ("θ", "logit"),
            ("standardized", "raw"),
            ("U", "1", "2"),
            ("B", "M"),
            ("T", "U", "B"),
        ),
        "require_tex": True,
    },
    "supplement": {
        "source": ROOT / "manuscript" / "supplement.qmd",
        "docx": ROOT / "manuscript" / "supplement.docx",
        "figure_prefix": "Figure S",
        "expected_figures": 6,
        "expected_equations": 16,
        "equation_markers": (
            ("Z", "BB", "MB", "SB", "AA"),
            ("V", "finite"),
            ("θ", "V", "Z"),
            ("ASMMAT1", "ASMMAT2", "ASSSCI1", "ASSSCI2"),
            ("BSMMAT1", "BSMMAT2", "BSSSCI1", "BSSSCI2"),
            ("U", "1", "2"),
            ("θ", "U", "M"),
            ("B", "M"),
            ("T", "U", "B"),
            ("d", "475"),
            ("logit", "β"),
            ("q", "E", "1", "9"),
            ("θ", "standardized", "logit"),
            ("standardized", "raw", "model", "support"),
            ("w", "P", "π", ".05"),
            ("D", "20", "P", "AA", "BB"),
        ),
        "require_tex": False,
    },
}
REVIEW_DOCUMENT_NAMES = ("main", "supplement")

TITLE_PAGE_SOURCE = ROOT / "submission" / "title_page_template.qmd"
TITLE_PAGE_DOCX = ROOT / "submission" / "title_page_template.docx"
HYPERLINK_RELATIONSHIP = f"{R}/hyperlink"
TITLE_PAGE_DOI_URL_PATTERN = re.compile(
    r"(?<![A-Za-z0-9])https://doi[.]org/[^\s<>\"']+"
)
TITLE_PAGE_EMAIL_PATTERN = re.compile(
    r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I
)
TITLE_PAGE_ORCID_PATTERN = re.compile(
    r"\b\d{4}-\d{4}-\d{4}-\d{3}[\dX]\b", re.I
)
TITLE_PAGE_CORRESPONDING_AUTHOR_MARKER = "**Corresponding author:**"
TITLE_PAGE_CORRESPONDING_AUTHOR_LINE = re.compile(
    r"^[ \t]*(?:\\\*[ \t]+)?\*\*Corresponding author:\*\*[ \t]*"
    r"(\S(?:.*\S)?)[ \t]*$"
)
TITLE_PAGE_REQUIRED_PHRASES = (
    "No funding was received for this research.",
    "no competing interests",
)
SUPPLEMENT_FIGURE_LABELS = tuple(f"Figure S{index}." for index in range(1, 7))


@dataclass(frozen=True)
class TitlePageContract:
    doi_url: str
    emails: tuple[str, ...]
    orcid: str
    corresponding_author: str
    required_phrases: tuple[str, ...]


def fail(message: str) -> None:
    raise RuntimeError(message)


def valid_orcid_checksum(value: str) -> bool:
    compact = value.replace("-", "").upper()
    total = 0
    for character in compact[:-1]:
        total = (total + int(character)) * 2
    result = (12 - (total % 11)) % 11
    expected = "X" if result == 10 else str(result)
    return compact[-1] == expected


def derive_title_page_contract(source: Path) -> TitlePageContract:
    if not source.is_file() or source.is_symlink():
        fail("Confidential title-page source is missing or unsafe")
    text = source.read_text(encoding="utf-8")
    if "\x00" in text:
        fail("Confidential title-page source contains a null byte")

    doi_urls = [match.group(0) for match in TITLE_PAGE_DOI_URL_PATTERN.finditer(text)]
    if text.casefold().count("doi.org/") != len(doi_urls):
        fail("Confidential title-page source contains a malformed DOI resolver URL")
    if len(doi_urls) != 2 or doi_urls[0] != doi_urls[1]:
        fail(
            "Confidential title-page source must contain exactly two identical "
            "HTTPS DOI URLs"
        )
    doi_suffix = doi_urls[0].removeprefix("https://doi.org/")
    if re.fullmatch(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", doi_suffix, re.I) is None:
        fail("Confidential title-page source contains a malformed DOI URL")

    emails_by_key: dict[str, str] = {}
    for email in TITLE_PAGE_EMAIL_PATTERN.findall(text):
        key = email.casefold()
        previous = emails_by_key.get(key)
        if previous is not None and previous != email:
            fail("Confidential title-page source contains ambiguous email casing")
        emails_by_key.setdefault(key, email)
    if len(emails_by_key) != 2:
        fail(
            "Confidential title-page source must contain exactly two distinct "
            "email addresses"
        )

    orcids = TITLE_PAGE_ORCID_PATTERN.findall(text)
    if len(orcids) != 1 or not valid_orcid_checksum(orcids[0]):
        fail("Confidential title-page source must contain exactly one valid ORCID")

    if text.count(TITLE_PAGE_CORRESPONDING_AUTHOR_MARKER) != 1:
        fail(
            "Confidential title-page source must contain exactly one "
            "corresponding-author line"
        )
    corresponding_lines = [
        line
        for line in text.splitlines()
        if TITLE_PAGE_CORRESPONDING_AUTHOR_MARKER in line
    ]
    if len(corresponding_lines) != 1:
        fail("Confidential title-page corresponding-author line is ambiguous")
    corresponding_match = TITLE_PAGE_CORRESPONDING_AUTHOR_LINE.fullmatch(
        corresponding_lines[0]
    )
    if corresponding_match is None:
        fail("Confidential title-page corresponding-author line is malformed")
    corresponding_author = corresponding_match.group(1).strip()
    if not corresponding_author:
        fail("Confidential title-page corresponding-author name is empty")

    missing_phrases = [
        phrase for phrase in TITLE_PAGE_REQUIRED_PHRASES if phrase not in text
    ]
    if missing_phrases:
        fail("Confidential title-page source lacks a required declaration phrase")

    return TitlePageContract(
        doi_url=doi_urls[0],
        emails=tuple(emails_by_key.values()),
        orcid=orcids[0],
        corresponding_author=corresponding_author,
        required_phrases=TITLE_PAGE_REQUIRED_PHRASES,
    )


def qn(namespace: str, local: str) -> str:
    return f"{{{namespace}}}{local}"


def parse_xml(data: bytes, label: str) -> ET.Element:
    try:
        return ET.fromstring(data)
    except ET.ParseError as error:
        fail(f"Malformed XML in {label}: {error}")
    raise AssertionError("unreachable")


def safe_member_name(name: str) -> None:
    if not name or "\x00" in name or "\\" in name:
        fail(f"Unsafe DOCX member name: {name!r}")
    path = PurePosixPath(name)
    if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
        fail(f"Unsafe DOCX member name: {name!r}")


def read_docx(path: Path) -> dict[str, bytes]:
    if not path.is_file() or path.is_symlink():
        fail(f"Rendered DOCX is missing or unsafe: {path.relative_to(ROOT)}")
    with zipfile.ZipFile(path, "r") as archive:
        infos = archive.infolist()
        names = [info.filename for info in infos]
        for name in names:
            safe_member_name(name)
        if len(names) != len(set(names)):
            fail(f"DOCX contains duplicate members: {path.relative_to(ROOT)}")
        for info in infos:
            mode = info.external_attr >> 16
            if stat.S_ISLNK(mode):
                fail(f"DOCX contains a symbolic-link member: {info.filename}")
        if archive.testzip() is not None:
            fail(f"DOCX CRC validation failed: {path.relative_to(ROOT)}")
        return {name: archive.read(name) for name in names}


def zip_info(name: str) -> zipfile.ZipInfo:
    info = zipfile.ZipInfo(name, FIXED_ZIP_TIME)
    info.create_system = 3
    info.external_attr = (stat.S_IFREG | 0o644) << 16
    info.compress_type = zipfile.ZIP_DEFLATED
    info.extra = b""
    info.comment = b""
    return info


def docx_bytes(members: dict[str, bytes]) -> bytes:
    output = io.BytesIO()
    with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
        for name in sorted(members):
            archive.writestr(zip_info(name), members[name], compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)
    data = output.getvalue()
    with zipfile.ZipFile(io.BytesIO(data), "r") as archive:
        if archive.testzip() is not None:
            fail("Finalized DOCX failed CRC validation")
    return data


def replace_docx_atomically(path: Path, members: dict[str, bytes]) -> None:
    data = docx_bytes(members)
    descriptor, temporary_name = tempfile.mkstemp(
        prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
    )
    temporary = Path(temporary_name)
    try:
        with os.fdopen(descriptor, "wb") as stream:
            stream.write(data)
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, path)
    finally:
        if temporary.exists():
            temporary.unlink()


CUSTOM_PROPERTIES_PART = "docProps/custom.xml"
CUSTOM_PROPERTIES_RELATIONSHIP = (
    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"
    "custom-properties"
)
CUSTOM_PROPERTIES_CONTENT_TYPE = (
    "application/vnd.openxmlformats-officedocument.custom-properties+xml"
)
CUSTOM_PROPERTIES_RELATIONSHIP_PART = "docProps/_rels/custom.xml.rels"


def relationship_source_part(relationship_part: str) -> str | None:
    if relationship_part == "_rels/.rels":
        return None
    path = PurePosixPath(relationship_part)
    if path.suffix.casefold() != ".rels" or path.parent.name != "_rels":
        fail(f"DOCX relationship part is outside an OPC _rels directory: {relationship_part}")
    source_name = path.name[:-5]
    if not source_name:
        fail(f"DOCX relationship part has no source filename: {relationship_part}")
    parent = path.parent.parent
    return (parent / source_name).as_posix() if parent.as_posix() != "." else source_name


def resolve_internal_relationship_target(
    relationship_part: str, target: str
) -> str | None:
    if not target or "\\" in target or "\x00" in target:
        return None
    source = relationship_source_part(relationship_part)
    if target.startswith("/"):
        resolved = posixpath.normpath(target).lstrip("/")
    else:
        base = "" if source is None else posixpath.dirname(source)
        resolved = posixpath.normpath(posixpath.join(base, target))
    if not resolved or resolved in {".", ".."} or resolved.startswith("../"):
        return None
    return resolved


def inspect_custom_properties(members: dict[str, bytes]) -> tuple[
    list[str],
    list[tuple[str, ET.Element, ET.Element, str | None]],
    ET.Element,
    list[ET.Element],
    list[str],
]:
    required = ("_rels/.rels", "[Content_Types].xml")
    missing = [name for name in required if name not in members]
    if missing:
        fail(f"DOCX lacks package metadata required for finalization: {missing}")

    member_names = [
        name for name in members if name.casefold() == CUSTOM_PROPERTIES_PART.casefold()
    ]
    custom_relationship_parts = [
        name
        for name in members
        if name.casefold() == CUSTOM_PROPERTIES_RELATIONSHIP_PART.casefold()
    ]
    custom_relationships: list[
        tuple[str, ET.Element, ET.Element, str | None]
    ] = []
    for name in sorted(
        member for member in members if member.casefold().endswith(".rels")
    ):
        root = parse_xml(members[name], name)
        if root.tag != qn(PKG_REL, "Relationships"):
            fail(f"DOCX relationship part has an unexpected root: {name}")
        for relationship in root:
            if relationship.tag != qn(PKG_REL, "Relationship"):
                fail(f"DOCX relationship part contains an unknown element: {name}")
            relationship_type = relationship.attrib.get("Type", "")
            target = relationship.attrib.get("Target", "")
            target_mode = relationship.attrib.get("TargetMode", "")
            resolved = None
            if target_mode.casefold() in {"", "internal"}:
                resolved = resolve_internal_relationship_target(name, target)
            if (
                relationship_type.casefold()
                == CUSTOM_PROPERTIES_RELATIONSHIP.casefold()
                or (resolved is not None and resolved.casefold() == CUSTOM_PROPERTIES_PART.casefold())
            ):
                custom_relationships.append((name, root, relationship, resolved))

    content_types = parse_xml(members["[Content_Types].xml"], "[Content_Types].xml")
    if content_types.tag != qn(CONTENT_TYPES, "Types"):
        fail("DOCX content-types part has an unexpected root")
    declarations = [
        element
        for element in content_types
        if element.attrib.get("PartName", "").lstrip("/").casefold()
        == CUSTOM_PROPERTIES_PART.casefold()
        or element.attrib.get("ContentType", "").casefold()
        == CUSTOM_PROPERTIES_CONTENT_TYPE.casefold()
    ]
    return (
        member_names,
        custom_relationships,
        content_types,
        declarations,
        custom_relationship_parts,
    )


def validate_no_custom_properties(members: dict[str, bytes]) -> None:
    (
        member_names,
        custom_relationships,
        _,
        declarations,
        custom_relationship_parts,
    ) = inspect_custom_properties(members)
    if member_names or custom_relationships or declarations or custom_relationship_parts:
        fail("DOCX retains custom document properties or their OPC declarations")


def remove_custom_properties(members: dict[str, bytes]) -> dict[str, bytes]:
    (
        member_names,
        custom_relationships,
        content_types,
        declarations,
        custom_relationship_parts,
    ) = inspect_custom_properties(members)
    if not (
        member_names
        or custom_relationships
        or declarations
        or custom_relationship_parts
    ):
        return members

    canonical_graph = (
        member_names == [CUSTOM_PROPERTIES_PART]
        and not custom_relationship_parts
        and len(custom_relationships) == 1
        and custom_relationships[0][0] == "_rels/.rels"
        and custom_relationships[0][2].attrib.get("Type")
        == CUSTOM_PROPERTIES_RELATIONSHIP
        and custom_relationships[0][2].attrib.get("TargetMode", "").casefold()
        in {"", "internal"}
        and custom_relationships[0][3] == CUSTOM_PROPERTIES_PART
        and len(declarations) == 1
        and declarations[0].tag == qn(CONTENT_TYPES, "Override")
        and declarations[0].attrib.get("PartName")
        == f"/{CUSTOM_PROPERTIES_PART}"
        and declarations[0].attrib.get("ContentType")
        == CUSTOM_PROPERTIES_CONTENT_TYPE
    )
    if not canonical_graph:
        fail("DOCX custom-property graph is partial, duplicate, or noncanonical")

    for name, content in members.items():
        if (
            name.casefold().startswith("word/")
            and name.casefold().endswith(".xml")
            and b"DOCPROPERTY" in content.upper()
        ):
            fail("DOCX contains a field that consumes a custom document property")

    relationship_name, relationship_root, relationship, _ = custom_relationships[0]
    relationship_root.remove(relationship)
    content_types.remove(declarations[0])
    output = dict(members)
    del output[CUSTOM_PROPERTIES_PART]
    output[relationship_name] = ET.tostring(
        relationship_root, encoding="utf-8", xml_declaration=True
    )
    output["[Content_Types].xml"] = ET.tostring(
        content_types, encoding="utf-8", xml_declaration=True
    )
    validate_no_custom_properties(output)
    return output


COMMENTS_PART = "word/comments.xml"
COMMENTS_RELATIONSHIP = (
    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
)
COMMENTS_CONTENT_TYPE = (
    "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"
)
REVISION_OR_COMMENT_LOCALS = {
    "commentRangeStart",
    "commentRangeEnd",
    "commentReference",
    "del",
    "ins",
    "moveFrom",
    "moveFromRangeEnd",
    "moveFromRangeStart",
    "moveTo",
    "moveToRangeEnd",
    "moveToRangeStart",
    "trackRevisions",
}


def remove_empty_comment_scaffold(members: dict[str, bytes]) -> dict[str, bytes]:
    comment_named_parts = sorted(
        name for name in members if "comment" in PurePosixPath(name).name.casefold()
    )
    if not comment_named_parts:
        return members
    if comment_named_parts != [COMMENTS_PART]:
        fail("DOCX contains an unsupported or extended comments feature")

    comments = parse_xml(members[COMMENTS_PART], COMMENTS_PART)
    if comments.tag != qn(W, "comments"):
        fail("DOCX comments part has an unexpected root")
    if next(comments.iter(qn(W, "comment")), None) is not None:
        fail("DOCX contains real comments; remove them before finalization")

    for name, content in members.items():
        lowered = name.casefold()
        if name == COMMENTS_PART or not (
            lowered.startswith("word/") and lowered.endswith(".xml")
        ):
            continue
        root = parse_xml(content, name)
        if any(
            element.tag.rsplit("}", 1)[-1] in REVISION_OR_COMMENT_LOCALS
            for element in root.iter()
        ):
            fail("DOCX contains comments or tracked revisions")

    comment_relationships: list[tuple[str, ET.Element, ET.Element, str | None]] = []
    for name in sorted(
        member for member in members if member.casefold().endswith(".rels")
    ):
        root = parse_xml(members[name], name)
        for relationship in root:
            if "comment" in relationship.attrib.get("Type", "").casefold():
                resolved = None
                if relationship.attrib.get("TargetMode", "").casefold() in {
                    "",
                    "internal",
                }:
                    resolved = resolve_internal_relationship_target(
                        name, relationship.attrib.get("Target", "")
                    )
                comment_relationships.append((name, root, relationship, resolved))
    if len(comment_relationships) != 1:
        fail("Empty DOCX comments scaffold has an unexpected relationship graph")
    relationship_name, relationship_root, relationship, resolved = comment_relationships[0]
    if (
        relationship_name != "word/_rels/document.xml.rels"
        or relationship.attrib.get("Type") != COMMENTS_RELATIONSHIP
        or relationship.attrib.get("TargetMode", "").casefold()
        not in {"", "internal"}
        or resolved != COMMENTS_PART
    ):
        fail("Empty DOCX comments scaffold has a noncanonical relationship")

    content_types = parse_xml(members["[Content_Types].xml"], "[Content_Types].xml")
    declarations = [
        element
        for element in content_types
        if "comment"
        in " ".join(
            (
                element.attrib.get("PartName", ""),
                element.attrib.get("ContentType", ""),
            )
        ).casefold()
    ]
    if (
        len(declarations) != 1
        or declarations[0].tag != qn(CONTENT_TYPES, "Override")
        or declarations[0].attrib.get("PartName") != f"/{COMMENTS_PART}"
        or declarations[0].attrib.get("ContentType") != COMMENTS_CONTENT_TYPE
    ):
        fail("Empty DOCX comments scaffold has a noncanonical content type")

    relationship_root.remove(relationship)
    content_types.remove(declarations[0])
    output = dict(members)
    del output[COMMENTS_PART]
    output[relationship_name] = ET.tostring(
        relationship_root, encoding="utf-8", xml_declaration=True
    )
    output["[Content_Types].xml"] = ET.tostring(
        content_types, encoding="utf-8", xml_declaration=True
    )
    return output


def sanitize_identity_metadata(members: dict[str, bytes]) -> dict[str, bytes]:
    output = dict(members)
    core_name = "docProps/core.xml"
    if core_name in output:
        root = parse_xml(output[core_name], core_name)
        remove_locals = {
            "creator",
            "lastModifiedBy",
            "created",
            "modified",
            "lastPrinted",
        }
        for child in list(root):
            if child.tag.rsplit("}", 1)[-1] in remove_locals:
                root.remove(child)
        output[core_name] = ET.tostring(
            root, encoding="utf-8", xml_declaration=True
        )

    app_name = "docProps/app.xml"
    if app_name in output:
        root = parse_xml(output[app_name], app_name)
        for element in root.iter():
            if element.tag.rsplit("}", 1)[-1] in {"Company", "Manager"}:
                element.text = None
        output[app_name] = ET.tostring(
            root, encoding="utf-8", xml_declaration=True
        )
    return output


def validate_docx_safety(
    members: dict[str, bytes],
    *,
    allowed_emails: frozenset[str] = frozenset(),
    allowed_orcids: frozenset[str] = frozenset(),
) -> None:
    forbidden_member_markers = (
        "comments",
        "people",
        "person.xml",
        "vbaproject",
        "activex",
        "embeddings/",
        "_xmlsignatures/",
        "customxml/",
    )
    forbidden_type_markers = (
        "comment",
        "custom-properties",
        "digital-signature",
        "digitalsignature",
        "macroenabled",
        "vbaproject",
        "oleobject",
        "activex",
        "customxml",
    )
    local_markers = (
        b"/" + b"users" + b"/",
        b"/" + b"home" + b"/",
        b"\\" + b"users" + b"\\",
        b"\\" + b"home" + b"\\",
        b"file" + b":",
    )
    machine_tokens = {
        str(Path.home()).casefold().encode("utf-8"),
        Path.home().name.casefold().encode("utf-8"),
        os.uname().nodename.casefold().encode("utf-8"),
    }
    email_pattern = re.compile(
        r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I
    )
    orcid_pattern = re.compile(r"\b\d{4}-\d{4}-\d{4}-\d{3}[\dX]\b", re.I)
    permitted_emails = {value.casefold() for value in allowed_emails}
    permitted_orcids = {value.casefold() for value in allowed_orcids}

    for name, content in members.items():
        lowered_name = name.casefold()
        if lowered_name == CUSTOM_PROPERTIES_PART.casefold() or any(
            marker in lowered_name for marker in forbidden_member_markers
        ):
            fail(f"DOCX contains a forbidden review, identity, or active part: {name}")
        if not lowered_name.endswith((".xml", ".rels")):
            continue
        if b"\x00" in content:
            fail(f"DOCX contains an unsafe XML encoding: {name}")
        upper = content.upper()
        if b"<!DOCTYPE" in upper or b"<!ENTITY" in upper:
            fail(f"DOCX contains a forbidden XML declaration: {name}")
        lowered = content.lower()
        if any(marker in lowered for marker in local_markers) or any(
            token and token in lowered for token in machine_tokens
        ):
            fail(f"DOCX contains a local path or machine identifier: {name}")
        text = content.decode("utf-8", errors="strict")
        unexpected_emails = [
            value
            for value in email_pattern.findall(text)
            if value.casefold() not in permitted_emails
        ]
        unexpected_orcids = [
            value
            for value in orcid_pattern.findall(text)
            if value.casefold() not in permitted_orcids
        ]
        if unexpected_emails or unexpected_orcids:
            fail(f"DOCX contains email-like or ORCID-like metadata: {name}")
        root = parse_xml(content, name)
        if any(
            element.tag.rsplit("}", 1)[-1] in REVISION_OR_COMMENT_LOCALS
            for element in root.iter()
        ):
            fail(f"DOCX contains comments or tracked revisions: {name}")
        if lowered_name.endswith(".rels"):
            for relationship in root:
                relationship_type = relationship.attrib.get("Type", "").casefold()
                if any(marker in relationship_type for marker in forbidden_type_markers):
                    fail(f"DOCX contains a forbidden relationship type: {name}")
        if name == "[Content_Types].xml":
            for declaration in root:
                value = " ".join(declaration.attrib.values()).casefold()
                if any(marker in value for marker in forbidden_type_markers):
                    fail("DOCX contains a forbidden content-type declaration")
        if name in {"docProps/core.xml", "docProps/app.xml"}:
            identity_locals = {
                "creator",
                "lastModifiedBy",
                "created",
                "modified",
                "lastPrinted",
                "Company",
                "Manager",
            }
            if any(
                element.tag.rsplit("}", 1)[-1] in identity_locals
                and (element.text or "").strip()
                for element in root.iter()
            ):
                fail(f"DOCX retains identifying or volatile properties: {name}")


def validate_anonymous_docx(members: dict[str, bytes]) -> None:
    validate_docx_safety(members)


def relationship_part_for_source(source_part: str) -> str:
    path = PurePosixPath(source_part)
    return (path.parent / "_rels" / f"{path.name}.rels").as_posix()


def relationship_reference_ids(root: ET.Element) -> set[str]:
    namespace_prefix = f"{{{R}}}"
    return {
        value
        for element in root.iter()
        for attribute, value in element.attrib.items()
        if attribute.startswith(namespace_prefix)
    }


def inspect_relationship_graph(
    members: dict[str, bytes],
    allowed_external: tuple[str, str] | None = None,
) -> dict[str, tuple[ET.Element, dict[str, ET.Element]]]:
    graph: dict[str, tuple[ET.Element, dict[str, ET.Element]]] = {}
    for name in sorted(
        member for member in members if member.casefold().endswith(".rels")
    ):
        root = parse_xml(members[name], name)
        if root.tag != qn(PKG_REL, "Relationships"):
            fail(f"DOCX relationship part has an unexpected root: {name}")
        source = relationship_source_part(name)
        if source is not None and source not in members:
            fail(f"DOCX relationship part has no source part: {name}")

        relationships_by_id: dict[str, ET.Element] = {}
        for relationship in root:
            if relationship.tag != qn(PKG_REL, "Relationship"):
                fail(f"DOCX relationship part contains an unknown element: {name}")
            relationship_id = relationship.attrib.get("Id", "")
            relationship_type = relationship.attrib.get("Type", "")
            target = relationship.attrib.get("Target", "")
            target_mode = relationship.attrib.get("TargetMode", "")
            if not relationship_id or relationship_id in relationships_by_id:
                fail(f"DOCX relationship IDs are empty or duplicated: {name}")
            if not relationship_type or not target:
                fail(f"DOCX relationship lacks a type or target: {name}")
            relationships_by_id[relationship_id] = relationship

            if target_mode in {"", "Internal"}:
                resolved = resolve_internal_relationship_target(name, target)
                if resolved is None or resolved not in members:
                    fail(
                        f"DOCX internal relationship target is missing or unsafe: "
                        f"{name}#{relationship_id}"
                    )
            elif target_mode == "External":
                if allowed_external is None or (
                    relationship_type,
                    target,
                ) != allowed_external:
                    fail(
                        f"DOCX contains a forbidden or noncanonical external "
                        f"relationship: {name}#{relationship_id}"
                    )
            else:
                fail(
                    f"DOCX relationship has a noncanonical target mode: "
                    f"{name}#{relationship_id}"
                )
        graph[name] = (root, relationships_by_id)

    for name, content in members.items():
        lowered_name = name.casefold()
        if not lowered_name.endswith(".xml") or lowered_name.endswith(".rels"):
            continue
        root = parse_xml(content, name)
        references = relationship_reference_ids(root)
        if not references:
            continue
        relationship_part = relationship_part_for_source(name)
        if relationship_part not in graph:
            fail(f"DOCX source part has relationship references but no .rels part: {name}")
        relationships_by_id = graph[relationship_part][1]
        dangling = sorted(references.difference(relationships_by_id))
        if dangling:
            fail(f"DOCX source part has dangling relationship references: {name}: {dangling}")
    return graph


def word_visible_text(root: ET.Element) -> str:
    return "".join(
        element.text or ""
        for element in root.iter()
        if element.tag in {qn(W, "t"), qn(M, "t")}
    )


def unwrap_title_page_doi_hyperlinks(
    members: dict[str, bytes], contract: TitlePageContract
) -> dict[str, bytes]:
    graph = inspect_relationship_graph(
        members,
        allowed_external=(HYPERLINK_RELATIONSHIP, contract.doi_url),
    )
    parsed_parts: dict[str, ET.Element] = {}
    hyperlinks: list[tuple[str, ET.Element, ET.Element]] = []
    for name in sorted(
        member
        for member in members
        if member.casefold().endswith(".xml")
        and not member.casefold().endswith(".rels")
    ):
        root = parse_xml(members[name], name)
        parsed_parts[name] = root
        for parent in root.iter():
            for child in list(parent):
                if child.tag == qn(W, "hyperlink"):
                    hyperlinks.append((name, parent, child))

    if len(hyperlinks) != 2:
        fail(
            f"Confidential title page must contain exactly two DOI hyperlinks; "
            f"found {len(hyperlinks)}"
        )

    changed_parts: set[str] = set()
    for name, parent, hyperlink in hyperlinks:
        if name != "word/document.xml":
            fail("Confidential title-page DOI hyperlinks must be in word/document.xml")
        relationship_id = hyperlink.attrib.get(qn(R, "id"), "")
        relationship_part = relationship_part_for_source(name)
        relationship = graph.get(relationship_part, (None, {}))[1].get(
            relationship_id
        )
        if relationship is None:
            fail("Confidential title-page hyperlink has a dangling relationship")
        if (
            relationship.attrib.get("Type") != HYPERLINK_RELATIONSHIP
            or relationship.attrib.get("Target") != contract.doi_url
            or relationship.attrib.get("TargetMode") != "External"
        ):
            fail("Confidential title-page DOI hyperlink relationship is noncanonical")
        if word_visible_text(hyperlink) != contract.doi_url:
            fail("Confidential title-page DOI hyperlink has unexpected visible text")

        position = list(parent).index(hyperlink)
        children = list(hyperlink)
        parent.remove(hyperlink)
        for offset, child in enumerate(children):
            parent.insert(position + offset, child)
        changed_parts.add(name)

    output = dict(members)
    for name in changed_parts:
        output[name] = serialize(parsed_parts[name])

    for relationship_part, (root, _) in graph.items():
        source = relationship_source_part(relationship_part)
        references: set[str] = set()
        if source is not None:
            references = relationship_reference_ids(
                parse_xml(output[source], source)
            )
        removed = False
        for relationship in list(root):
            if relationship.attrib.get("TargetMode") != "External":
                continue
            relationship_id = relationship.attrib.get("Id", "")
            if relationship_id in references:
                fail(
                    "Confidential title page retains a referenced external "
                    "relationship after hyperlink unwrapping"
                )
            root.remove(relationship)
            removed = True
        if removed:
            output[relationship_part] = serialize(root)

    inspect_relationship_graph(output)
    return output


def validate_confidential_title_docx(
    members: dict[str, bytes], contract: TitlePageContract
) -> None:
    if len(members) != len({name.casefold() for name in members}):
        fail("Confidential title-page DOCX contains case-colliding package members")
    validate_no_custom_properties(members)
    validate_docx_safety(
        members,
        allowed_emails=frozenset(contract.emails),
        allowed_orcids=frozenset((contract.orcid,)),
    )
    inspect_relationship_graph(members)

    document_name = "word/document.xml"
    if document_name not in members:
        fail("Confidential title-page DOCX lacks word/document.xml")
    document = parse_xml(members[document_name], document_name)
    hyperlinks = 0
    package_doi_count = 0
    visible_identity = (
        contract.corresponding_author,
        contract.orcid,
        *contract.emails,
    )
    forbidden_active_locals = {"control", "object", "oleobject"}
    for name, content in members.items():
        if not name.casefold().endswith(".xml"):
            continue
        root = parse_xml(content, name)
        hyperlinks += sum(1 for _ in root.iter(qn(W, "hyperlink")))
        visible = word_visible_text(root)
        package_doi_count += visible.count(contract.doi_url)
        if name != document_name and any(value in visible for value in visible_identity):
            fail("Confidential title-page identity appears outside the visible document")
        if any(
            element.tag.rsplit("}", 1)[-1].casefold() in forbidden_active_locals
            for element in root.iter()
        ):
            fail(f"Confidential title page contains active or embedded XML: {name}")
        field_instructions = [
            element.text or "" for element in root.iter(qn(W, "instrText"))
        ] + [
            element.attrib.get(qn(W, "instr"), "")
            for element in root.iter(qn(W, "fldSimple"))
        ]
        if any("HYPERLINK" in instruction.upper() for instruction in field_instructions):
            fail("Confidential title page retains a hyperlink field")

    if hyperlinks:
        fail("Confidential title page must contain zero w:hyperlink elements")
    visible_document = word_visible_text(document)
    required_text = (
        contract.corresponding_author,
        contract.orcid,
        *contract.emails,
        *contract.required_phrases,
    )
    missing = [value for value in required_text if value not in visible_document]
    if missing:
        fail(f"Confidential title page lacks required visible text: {missing}")
    if (
        visible_document.count(contract.doi_url) != 2
        or package_doi_count != 2
    ):
        fail("Confidential title page must show the exact DOI twice as plain text")


def source_display_equations(source: Path, expected: int) -> tuple[str, ...]:
    text = source.read_text(encoding="utf-8")
    if "\\[" in text or "\\]" in text:
        fail(
            f"{source.relative_to(ROOT)} contains \\[ or \\] display delimiters; "
            "use stand-alone $$ delimiters for Pandoc-safe DOCX equations"
        )

    misplaced = [
        number
        for number, line in enumerate(text.splitlines(), start=1)
        if "$$" in line and line.strip() != "$$"
    ]
    if misplaced:
        fail(
            f"{source.relative_to(ROOT)} has a display delimiter that is not on its "
            f"own line: {misplaced}"
        )
    standalone_single_dollar_lines = [
        number
        for number, line in enumerate(text.splitlines(), start=1)
        if line.strip() == "$"
    ]
    if standalone_single_dollar_lines:
        fail(
            f"{source.relative_to(ROOT)} contains a stand-alone single $ delimiter "
            f"on line(s) {standalone_single_dollar_lines}; use $$ for display math"
        )
    inline_delimiters = re.findall(r"(?<!\\\\)(?<!\$)\$(?!\$)", text)
    if len(inline_delimiters) % 2:
        fail(
            f"{source.relative_to(ROOT)} contains an odd number of inline $ "
            "delimiters"
        )

    equations: list[str] = []
    current: list[str] = []
    inside = False
    opening_line = 0
    for number, line in enumerate(text.splitlines(), start=1):
        if line.strip() == "$$":
            if inside:
                equation = "\n".join(current).strip()
                if not equation:
                    fail(
                        f"{source.relative_to(ROOT)} contains an empty display equation "
                        f"opened on line {opening_line}"
                    )
                equations.append(equation)
                current = []
                inside = False
            else:
                inside = True
                opening_line = number
            continue
        if inside:
            current.append(line)
    if inside:
        fail(
            f"{source.relative_to(ROOT)} has an unclosed display equation opened "
            f"on line {opening_line}"
        )
    if len(equations) != expected:
        fail(
            f"{source.relative_to(ROOT)} must contain exactly {expected} display "
            f"equations; found {len(equations)}"
        )
    return tuple(equations)


def normalized_math_text(text: str) -> str:
    return re.sub(r"\s+", "", unicodedata.normalize("NFKC", text)).casefold()


def validate_docx_equations(
    document: ET.Element,
    expected: int,
    marker_groups: tuple[tuple[str, ...], ...],
) -> None:
    if len(marker_groups) != expected:
        fail("Internal equation-marker contract does not match the expected count")
    paragraphs = list(document.iter(qn(M, "oMathPara")))
    if len(paragraphs) != expected:
        fail(
            f"Rendered DOCX contains {len(paragraphs)} OMML display equations; "
            f"expected exactly {expected}"
        )

    for index, (paragraph, markers) in enumerate(
        zip(paragraphs, marker_groups), start=1
    ):
        math_nodes = list(paragraph.iter(qn(M, "oMath")))
        if not math_nodes:
            fail(f"DOCX display equation {index} contains no m:oMath node")
        node_texts: list[str] = []
        for node in math_nodes:
            node_text = "".join(
                element.text or "" for element in node.iter(qn(M, "t"))
            )
            if not normalized_math_text(node_text):
                fail(f"DOCX display equation {index} contains an empty m:oMath node")
            node_texts.append(node_text)
        rendered = normalized_math_text("".join(node_texts))
        missing = [
            marker
            for marker in markers
            if normalized_math_text(marker) not in rendered
        ]
        if missing:
            fail(
                f"DOCX display equation {index} lacks semantic marker(s) {missing}; "
                f"observed OMML text={''.join(node_texts)[:240]!r}"
            )

    visible_text = " ".join(
        element.text or ""
        for element in document.iter()
        if element.tag in {qn(W, "t"), qn(M, "t")}
    )
    lowered = visible_text.casefold()
    compact = re.sub(r"\s+", "", lowered)
    literal_forbidden = ("\\[", "\\]", "$$", "_s^{}")
    if any(marker in lowered for marker in literal_forbidden):
        fail("DOCX contains a visible raw or mangled math delimiter/string")
    compact_forbidden = ("[t=u+(1+)b,]", "[u_j=r")
    if any(marker in compact for marker in compact_forbidden):
        fail("DOCX contains a known mangled display-equation rendering")


def verify_rendered_freshness(path: Path, dependencies: tuple[Path, ...]) -> None:
    if not path.is_file() or path.is_symlink():
        fail(f"Rendered output is missing or unsafe: {path.relative_to(ROOT)}")
    rendered_time = path.stat().st_mtime_ns
    newer_inputs = [
        dependency
        for dependency in dependencies
        if dependency.stat().st_mtime_ns > rendered_time
    ]
    if newer_inputs:
        labels = ", ".join(
            str(dependency.relative_to(ROOT)) for dependency in newer_inputs
        )
        fail(
            f"{path.relative_to(ROOT)} predates render input(s): {labels}; "
            "rerender it before finalization"
        )


def validate_pdf_output(source: Path, dependencies: tuple[Path, ...]) -> None:
    path = source.with_suffix(".pdf")
    verify_rendered_freshness(path, dependencies)
    data = path.read_bytes()
    if len(data) < 1024 or re.match(rb"%PDF-\d\.\d", data[:16]) is None:
        fail(f"Rendered PDF has an invalid signature or size: {path.relative_to(ROOT)}")
    if b"startxref" not in data[-8192:] or b"%%EOF" not in data[-8192:]:
        fail(f"Rendered PDF lacks a complete cross-reference trailer: {path.relative_to(ROOT)}")


def validate_tex_output(
    source: Path,
    dependencies: tuple[Path, ...],
    equations: tuple[str, ...],
) -> None:
    path = source.with_suffix(".tex")
    verify_rendered_freshness(path, dependencies)
    text = path.read_text(encoding="utf-8")
    for marker in (r"\documentclass", r"\begin{document}", r"\end{document}"):
        if marker not in text:
            fail(f"Retained TeX lacks required structure {marker}: {path.relative_to(ROOT)}")
    compact = re.sub(r"\s+", "", text)
    cursor = 0
    for index, equation in enumerate(equations, start=1):
        expected = re.sub(r"\s+", "", equation)
        position = compact.find(expected, cursor)
        if position < 0:
            fail(
                f"Retained TeX does not preserve source display equation {index} "
                f"in order: {path.relative_to(ROOT)}"
            )
        cursor = position + len(expected)


def source_render_inputs(source: Path, expected_figures: int) -> tuple[str, tuple[Path, ...]]:
    text = source.read_text(encoding="utf-8")
    include_references = re.findall(
        r"\{\{<\s*include\s+([^\s>]+)\s*>\}\}", text
    )
    figure_references = re.findall(
        r"!\[[^\]]*\]\(([^)\n]+\.png)\)", text
    )
    if not include_references or len(include_references) != len(set(include_references)):
        fail(
            f"{source.relative_to(ROOT)} must contain distinct generated-fragment includes"
        )
    if len(figure_references) != expected_figures or len(figure_references) != len(
        set(figure_references)
    ):
        fail(
            f"{source.relative_to(ROOT)} must reference exactly {expected_figures} "
            "distinct figure PNGs"
        )

    metadata_references: list[str] = []
    for field in ("bibliography", "csl"):
        matches = re.findall(rf"(?m)^{field}:\s*([^\s#]+)\s*(?:#.*)?$", text)
        if len(matches) != 1:
            fail(f"{source.relative_to(ROOT)} must declare exactly one {field} file")
        metadata_references.extend(matches)

    dependencies = [source, Path(__file__).resolve()]
    for reference in metadata_references + include_references + figure_references:
        if Path(reference).is_absolute() or re.match(
            r"^[A-Za-z][A-Za-z0-9+.-]*:", reference
        ):
            fail(f"Render dependency must be a local relative path: {reference}")
        candidate = source.parent / reference
        if candidate.is_symlink():
            fail(f"Render dependency must not be a symbolic link: {reference}")
        resolved = candidate.resolve()
        try:
            resolved.relative_to(ROOT)
        except ValueError:
            fail(f"Render dependency escapes the project root: {reference}")
        if not resolved.is_file():
            fail(f"Render dependency is missing: {reference}")
        dependencies.append(resolved)

    unique_dependencies = tuple(dict.fromkeys(dependencies))
    return text, unique_dependencies


def verify_docx_freshness(source: Path, dependencies: tuple[Path, ...]) -> None:
    path = source.with_suffix(".docx")
    if not path.is_file() or path.is_symlink():
        fail(f"Rendered DOCX is missing or unsafe: {path.relative_to(ROOT)}")
    rendered_time = path.stat().st_mtime_ns
    newer_inputs = [
        dependency
        for dependency in dependencies
        if dependency.stat().st_mtime_ns > rendered_time
    ]
    if newer_inputs:
        labels = ", ".join(
            str(dependency.relative_to(ROOT)) for dependency in newer_inputs
        )
        fail(
            f"{path.relative_to(ROOT)} predates render input(s): {labels}; "
            "rerender the DOCX before finalization"
        )


def source_alt_texts(source: Path, expected: int) -> list[str]:
    text, dependencies = source_render_inputs(source, expected)
    verify_docx_freshness(source, dependencies)
    alternatives = [match.strip() for match in ALT_PATTERN.findall(text)]
    if len(alternatives) != expected:
        fail(
            f"{source.relative_to(ROOT)} must contain exactly {expected} explicit fig-alt values; "
            f"found {len(alternatives)}"
        )
    if any(len(value.split()) < 8 for value in alternatives) or len(set(alternatives)) != expected:
        fail(f"Figure alternatives in {source.relative_to(ROOT)} must be distinct and meaningful")
    return alternatives


def ensure_child(parent: ET.Element, tag: str, index: int | None = None) -> ET.Element:
    child = parent.find(tag)
    if child is None:
        child = ET.Element(tag)
        if index is None:
            parent.append(child)
        else:
            parent.insert(index, child)
    return child


def set_spacing(paragraph_properties: ET.Element, line: str) -> None:
    spacing = ensure_child(paragraph_properties, qn(W, "spacing"))
    spacing.set(qn(W, "line"), line)
    spacing.set(qn(W, "lineRule"), "auto")


def apply_double_spacing(document: ET.Element, styles: ET.Element) -> None:
    doc_defaults = ensure_child(styles, qn(W, "docDefaults"), 0)
    ppr_default = ensure_child(doc_defaults, qn(W, "pPrDefault"))
    default_ppr = ensure_child(ppr_default, qn(W, "pPr"))
    set_spacing(default_ppr, "480")

    normal_styles = [
        style
        for style in styles.findall(qn(W, "style"))
        if style.attrib.get(qn(W, "type")) == "paragraph"
        and style.attrib.get(qn(W, "styleId")) == "Normal"
    ]
    if len(normal_styles) != 1:
        fail("DOCX must contain exactly one Normal paragraph style")
    normal_ppr = ensure_child(normal_styles[0], qn(W, "pPr"))
    set_spacing(normal_ppr, "480")

    table_paragraphs = {
        id(paragraph)
        for table in document.iter(qn(W, "tbl"))
        for paragraph in table.iter(qn(W, "p"))
    }
    for paragraph in document.iter(qn(W, "p")):
        ppr = paragraph.find(qn(W, "pPr"))
        if ppr is None:
            ppr = ET.Element(qn(W, "pPr"))
            paragraph.insert(0, ppr)
        # Body, headings, captions, declarations, and references are double-spaced.
        # Table cells remain single-spaced to preserve editable-table legibility.
        set_spacing(ppr, "240" if id(paragraph) in table_paragraphs else "480")


def paragraph_has_content(paragraph: ET.Element) -> bool:
    if any(
        (element.text or "").strip()
        for element in paragraph.iter()
        if element.tag in {qn(W, "t"), qn(M, "t")}
    ):
        return True
    content_tags = {
        qn(W, "br"),
        qn(W, "drawing"),
        qn(W, "object"),
        qn(W, "pict"),
        qn(W, "tab"),
        qn(M, "oMath"),
    }
    return any(element.tag in content_tags for element in paragraph.iter())


def supplement_figure_caption_pairs(
    document: ET.Element,
) -> list[tuple[ET.Element, ET.Element]]:
    paragraphs = list(document.iter(qn(W, "p")))
    locations: dict[str, tuple[int, ET.Element]] = {}
    for label in SUPPLEMENT_FIGURE_LABELS:
        matches = [
            (index, paragraph)
            for index, paragraph in enumerate(paragraphs)
            if word_visible_text(paragraph).strip().startswith(label)
        ]
        if len(matches) != 1:
            fail(
                f"Supplement DOCX must contain exactly one manual {label} "
                f"caption paragraph; found {len(matches)}"
            )
        locations[label] = matches[0]

    indices = [locations[label][0] for label in SUPPLEMENT_FIGURE_LABELS]
    if indices != sorted(indices):
        fail("Supplement Figure S1–Figure S6 captions are out of order")

    pairs: list[tuple[ET.Element, ET.Element]] = []
    for label in SUPPLEMENT_FIGURE_LABELS:
        index, caption = locations[label]
        following = next(
            (
                paragraph
                for paragraph in paragraphs[index + 1 :]
                if paragraph_has_content(paragraph)
            ),
            None,
        )
        if following is None or next(
            following.iter(qn(W, "drawing")), None
        ) is None:
            fail(
                f"Supplement {label} caption must be followed by a nonempty "
                "paragraph containing a drawing"
            )
        pairs.append((caption, following))
    return pairs


def apply_supplement_caption_pagination(document: ET.Element) -> None:
    for caption, _ in supplement_figure_caption_pairs(document):
        properties = caption.find(qn(W, "pPr"))
        if properties is None:
            properties = ET.Element(qn(W, "pPr"))
            caption.insert(0, properties)
        existing: dict[str, ET.Element] = {}
        for local in ("keepNext", "keepLines"):
            matches = properties.findall(qn(W, local))
            if len(matches) > 1:
                fail(f"Supplement caption contains duplicate w:{local} properties")
            if matches:
                existing[local] = matches[0]
                properties.remove(matches[0])

        children = list(properties)
        paragraph_style = properties.find(qn(W, "pStyle"))
        position = children.index(paragraph_style) + 1 if paragraph_style is not None else 0
        keep_next = existing.get("keepNext", ET.Element(qn(W, "keepNext")))
        keep_next.set(qn(W, "val"), "true")
        keep_lines = existing.get("keepLines", ET.Element(qn(W, "keepLines")))
        keep_lines.set(qn(W, "val"), "true")
        properties.insert(position, keep_next)
        properties.insert(position + 1, keep_lines)


def validate_supplement_caption_pagination(document: ET.Element) -> None:
    true_values = {"1", "on", "true"}
    for caption, _ in supplement_figure_caption_pairs(document):
        properties = caption.find(qn(W, "pPr"))
        if properties is None:
            fail("Supplement figure caption lacks paragraph properties")
        for local in ("keepNext", "keepLines"):
            matches = properties.findall(qn(W, local))
            if len(matches) != 1:
                fail(f"Supplement figure caption must have exactly one w:{local}")
            value = matches[0].attrib.get(qn(W, "val"))
            if value is not None and value.casefold() not in true_values:
                fail(f"Supplement figure caption has disabled w:{local}")


def apply_figure_alternatives(
    document: ET.Element, alternatives: list[str], figure_prefix: str
) -> None:
    drawing_properties = list(document.iter(qn(WP, "docPr")))
    if len(drawing_properties) != len(alternatives):
        fail(
            f"Rendered drawing count {len(drawing_properties)} does not match "
            f"the {len(alternatives)} source figure alternatives"
        )
    for index, (properties, alternative) in enumerate(zip(drawing_properties, alternatives), start=1):
        properties.set("title", f"{figure_prefix}{index}")
        properties.set("descr", alternative)

    # Some readers consult the non-visual picture properties rather than wp:docPr.
    picture_properties = list(document.iter(qn(PIC, "cNvPr")))
    if len(picture_properties) == len(alternatives):
        for index, (properties, alternative) in enumerate(zip(picture_properties, alternatives), start=1):
            properties.set("name", f"{figure_prefix}{index}")
            properties.set("descr", alternative)


def next_relationship_id(relationships: ET.Element) -> str:
    used = {element.attrib.get("Id", "") for element in relationships.findall(qn(PKG_REL, "Relationship"))}
    number = 1
    while f"rId{number}" in used:
        number += 1
    return f"rId{number}"


def resolve_document_target(target: str) -> str:
    if not target or "\\" in target or "?" in target or "#" in target:
        fail(f"Unsafe DOCX relationship target: {target!r}")
    resolved = (
        posixpath.normpath(target).lstrip("/")
        if target.startswith("/")
        else posixpath.normpath(posixpath.join("word", target))
    )
    if resolved.startswith("../") or resolved == "..":
        fail(f"DOCX relationship target escapes the package: {target!r}")
    return resolved


def footer_xml() -> ET.Element:
    footer = ET.Element(qn(W, "ftr"))
    paragraph = ET.SubElement(footer, qn(W, "p"))
    properties = ET.SubElement(paragraph, qn(W, "pPr"))
    justification = ET.SubElement(properties, qn(W, "jc"))
    justification.set(qn(W, "val"), "center")
    set_spacing(properties, "240")

    begin_run = ET.SubElement(paragraph, qn(W, "r"))
    begin = ET.SubElement(begin_run, qn(W, "fldChar"))
    begin.set(qn(W, "fldCharType"), "begin")

    instruction_run = ET.SubElement(paragraph, qn(W, "r"))
    instruction = ET.SubElement(instruction_run, qn(W, "instrText"))
    instruction.set(qn(XML_NAMESPACE, "space"), "preserve")
    instruction.text = " PAGE "

    end_run = ET.SubElement(paragraph, qn(W, "r"))
    end = ET.SubElement(end_run, qn(W, "fldChar"))
    end.set(qn(W, "fldCharType"), "end")
    return footer


def footer_contains_page(footer: ET.Element) -> bool:
    return any(
        "PAGE" in (element.text or "").upper()
        for element in footer.iter(qn(W, "instrText"))
    )


def ensure_page_footer(
    members: dict[str, bytes], document: ET.Element, content_types: ET.Element
) -> tuple[ET.Element, dict[str, ET.Element]]:
    relationships_name = "word/_rels/document.xml.rels"
    relationships = parse_xml(members[relationships_name], relationships_name)
    sections = list(document.iter(qn(W, "sectPr")))
    if not sections:
        fail("DOCX contains no section properties for line or page numbering")

    relationship_by_id = {
        relation.attrib.get("Id", ""): relation
        for relation in relationships.findall(qn(PKG_REL, "Relationship"))
    }
    referenced_ids = {
        reference.attrib.get(qn(R, "id"), "")
        for section in sections
        for reference in section.findall(qn(W, "footerReference"))
        if reference.attrib.get(qn(W, "type"), "default") == "default"
    }
    referenced_ids.discard("")

    footer_parts: dict[str, ET.Element] = {}
    if referenced_ids:
        for relationship_id in referenced_ids:
            relation = relationship_by_id.get(relationship_id)
            if relation is None or relation.attrib.get("Type") != FOOTER_RELATIONSHIP:
                fail("A default footer reference has no valid footer relationship")
            part = resolve_document_target(relation.attrib.get("Target", ""))
            if part not in members:
                fail(f"Referenced footer part is missing: {part}")
            root = parse_xml(members[part], part)
            if not footer_contains_page(root):
                root.append(next(iter(footer_xml())))
            footer_parts[part] = root
        default_id = sorted(referenced_ids)[0]
    else:
        default_id = next_relationship_id(relationships)
        used_parts = set(members)
        footer_number = 1
        while f"word/footer{footer_number}.xml" in used_parts:
            footer_number += 1
        part = f"word/footer{footer_number}.xml"
        relation = ET.SubElement(relationships, qn(PKG_REL, "Relationship"))
        relation.set("Id", default_id)
        relation.set("Type", FOOTER_RELATIONSHIP)
        relation.set("Target", PurePosixPath(part).name)
        relationship_by_id[default_id] = relation
        footer_parts[part] = footer_xml()

    for section in sections:
        references = [
            reference
            for reference in section.findall(qn(W, "footerReference"))
            if reference.attrib.get(qn(W, "type"), "default") == "default"
        ]
        if not references:
            reference = ET.Element(qn(W, "footerReference"))
            reference.set(qn(W, "type"), "default")
            reference.set(qn(R, "id"), default_id)
            section.insert(0, reference)

    declared_parts = {
        element.attrib.get("PartName", "")
        for element in content_types.findall(qn(CONTENT_TYPES, "Override"))
    }
    for part in footer_parts:
        part_name = f"/{part}"
        if part_name not in declared_parts:
            override = ET.SubElement(content_types, qn(CONTENT_TYPES, "Override"))
            override.set("PartName", part_name)
            override.set("ContentType", FOOTER_CONTENT_TYPE)

    return relationships, footer_parts


def apply_line_numbering(document: ET.Element) -> None:
    sections = list(document.iter(qn(W, "sectPr")))
    if not sections:
        fail("DOCX contains no sections")
    for section in sections:
        existing = section.find(qn(W, "lnNumType"))
        if existing is None:
            existing = ET.SubElement(section, qn(W, "lnNumType"))
        existing.set(qn(W, "countBy"), "1")
        existing.set(qn(W, "restart"), "continuous")
        existing.set(qn(W, "distance"), "360")


def request_field_updates(settings: ET.Element) -> None:
    update = settings.find(qn(W, "updateFields"))
    if update is None:
        update = ET.SubElement(settings, qn(W, "updateFields"))
    update.set(qn(W, "val"), "true")


def serialize(root: ET.Element) -> bytes:
    return ET.tostring(root, encoding="utf-8", xml_declaration=True)


def apply_formatting(
    members: dict[str, bytes],
    alternatives: list[str],
    figure_prefix: str,
    supplement_captions: bool,
) -> dict[str, bytes]:
    members = remove_custom_properties(members)
    members = remove_empty_comment_scaffold(members)
    members = sanitize_identity_metadata(members)
    required = (
        "word/document.xml",
        "word/styles.xml",
        "word/settings.xml",
        "word/_rels/document.xml.rels",
        "[Content_Types].xml",
    )
    missing = [name for name in required if name not in members]
    if missing:
        fail(f"DOCX lacks required package parts: {missing}")

    document = parse_xml(members["word/document.xml"], "word/document.xml")
    styles = parse_xml(members["word/styles.xml"], "word/styles.xml")
    settings = parse_xml(members["word/settings.xml"], "word/settings.xml")
    content_types = parse_xml(members["[Content_Types].xml"], "[Content_Types].xml")

    apply_double_spacing(document, styles)
    if supplement_captions:
        apply_supplement_caption_pagination(document)
    apply_figure_alternatives(document, alternatives, figure_prefix)
    apply_line_numbering(document)
    relationships, footer_parts = ensure_page_footer(members, document, content_types)
    request_field_updates(settings)

    output = dict(members)
    output["word/document.xml"] = serialize(document)
    output["word/styles.xml"] = serialize(styles)
    output["word/settings.xml"] = serialize(settings)
    output["word/_rels/document.xml.rels"] = serialize(relationships)
    output["[Content_Types].xml"] = serialize(content_types)
    for part, root in footer_parts.items():
        output[part] = serialize(root)
    return output


def validate_formatting(
    members: dict[str, bytes],
    alternatives: list[str],
    figure_prefix: str,
    expected_equations: int,
    equation_markers: tuple[tuple[str, ...], ...],
    supplement_captions: bool,
) -> None:
    validate_no_custom_properties(members)
    validate_anonymous_docx(members)
    document = parse_xml(members["word/document.xml"], "word/document.xml")
    if supplement_captions:
        validate_supplement_caption_pagination(document)
    validate_docx_equations(document, expected_equations, equation_markers)
    styles = parse_xml(members["word/styles.xml"], "word/styles.xml")
    settings = parse_xml(members["word/settings.xml"], "word/settings.xml")
    relationships = parse_xml(
        members["word/_rels/document.xml.rels"], "word/_rels/document.xml.rels"
    )
    content_types = parse_xml(members["[Content_Types].xml"], "[Content_Types].xml")

    properties = list(document.iter(qn(WP, "docPr")))
    if len(properties) != len(alternatives):
        fail("Rendered DOCX drawing count differs from source alternatives")
    for index, (element, alternative) in enumerate(zip(properties, alternatives), start=1):
        if element.attrib.get("descr") != alternative:
            fail(f"Figure {index} lacks its exact source alternative description")
        if element.attrib.get("title") != f"{figure_prefix}{index}":
            fail(f"Figure {index} lacks its expected accessible title")

    defaults = styles.find(
        f"{qn(W, 'docDefaults')}/{qn(W, 'pPrDefault')}/{qn(W, 'pPr')}/{qn(W, 'spacing')}"
    )
    if defaults is None or defaults.attrib.get(qn(W, "line")) != "480":
        fail("DOCX default paragraph spacing is not double")

    table_paragraphs = {
        id(paragraph)
        for table in document.iter(qn(W, "tbl"))
        for paragraph in table.iter(qn(W, "p"))
    }
    for paragraph in document.iter(qn(W, "p")):
        spacing = paragraph.find(f"{qn(W, 'pPr')}/{qn(W, 'spacing')}")
        expected_line = "240" if id(paragraph) in table_paragraphs else "480"
        if (
            spacing is None
            or spacing.attrib.get(qn(W, "line")) != expected_line
            or spacing.attrib.get(qn(W, "lineRule")) != "auto"
        ):
            fail("DOCX contains a paragraph with an unexpected line-spacing rule")

    sections = list(document.iter(qn(W, "sectPr")))
    if not sections:
        fail("DOCX has no section properties")
    relationships_by_id = {
        element.attrib.get("Id", ""): element
        for element in relationships.findall(qn(PKG_REL, "Relationship"))
    }
    for section in sections:
        numbering = section.find(qn(W, "lnNumType"))
        if (
            numbering is None
            or numbering.attrib.get(qn(W, "countBy")) != "1"
            or numbering.attrib.get(qn(W, "restart")) != "continuous"
        ):
            fail("DOCX line numbering is not continuous and every line")
        references = [
            reference
            for reference in section.findall(qn(W, "footerReference"))
            if reference.attrib.get(qn(W, "type"), "default") == "default"
        ]
        if len(references) != 1:
            fail("Each DOCX section must have exactly one default footer")
        relationship_id = references[0].attrib.get(qn(R, "id"), "")
        relationship = relationships_by_id.get(relationship_id)
        if relationship is None or relationship.attrib.get("Type") != FOOTER_RELATIONSHIP:
            fail("DOCX page footer relationship is invalid")
        footer_part = resolve_document_target(relationship.attrib.get("Target", ""))
        if footer_part not in members:
            fail("DOCX page footer part is missing")
        footer = parse_xml(members[footer_part], footer_part)
        if not footer_contains_page(footer):
            fail("DOCX footer lacks a PAGE field")
        declarations = [
            element
            for element in content_types.findall(qn(CONTENT_TYPES, "Override"))
            if element.attrib.get("PartName") == f"/{footer_part}"
            and element.attrib.get("ContentType") == FOOTER_CONTENT_TYPE
        ]
        if len(declarations) != 1:
            fail("DOCX page footer content type is missing or duplicated")

    update = settings.find(qn(W, "updateFields"))
    if update is None or update.attrib.get(qn(W, "val")) != "true":
        fail("DOCX does not request field updates on open")


def finalize(name: str, verify_only: bool) -> None:
    definition = DOCUMENTS[name]
    source = definition["source"]
    path = definition["docx"]
    equations = source_display_equations(source, definition["expected_equations"])
    _, dependencies = source_render_inputs(source, definition["expected_figures"])
    alternatives = source_alt_texts(source, definition["expected_figures"])
    validate_pdf_output(source, dependencies)
    if definition["require_tex"]:
        validate_tex_output(source, dependencies, equations)
        tex_path = source.with_suffix(".tex")
        pdf_path = source.with_suffix(".pdf")
        if pdf_path.stat().st_mtime_ns < tex_path.stat().st_mtime_ns:
            fail(
                f"{pdf_path.relative_to(ROOT)} predates the retained "
                f"{tex_path.name}; render LaTeX before PDF"
            )

    members = read_docx(path)
    supplement_captions = name == "supplement"
    validation_args = (
        alternatives,
        definition["figure_prefix"],
        definition["expected_equations"],
        definition["equation_markers"],
        supplement_captions,
    )
    if not verify_only:
        members = apply_formatting(
            members,
            alternatives,
            definition["figure_prefix"],
            supplement_captions,
        )
        validate_formatting(members, *validation_args)
        replace_docx_atomically(path, members)
    else:
        validate_formatting(members, *validation_args)
    tex_note = " and retained TeX" if definition["require_tex"] else ""
    print(
        f"{path.relative_to(ROOT)}: anonymous reviewer document; journal DOCX "
        f"formatting, {len(alternatives)} figure alternatives, {len(equations)} "
        f"OMML display equations, and PDF{tex_note} verified"
    )


def finalize_title_page(verify_only: bool) -> None:
    contract = derive_title_page_contract(TITLE_PAGE_SOURCE)
    dependencies = (TITLE_PAGE_SOURCE, Path(__file__).resolve())
    verify_docx_freshness(TITLE_PAGE_SOURCE, dependencies)
    members = read_docx(TITLE_PAGE_DOCX)
    if not verify_only:
        members = remove_custom_properties(members)
        members = remove_empty_comment_scaffold(members)
        members = sanitize_identity_metadata(members)
        members = unwrap_title_page_doi_hyperlinks(members, contract)
        validate_confidential_title_docx(members, contract)
        replace_docx_atomically(TITLE_PAGE_DOCX, members)
    else:
        validate_confidential_title_docx(members, contract)
    print(
        f"{TITLE_PAGE_DOCX.relative_to(ROOT)}: confidential title page; "
        "required identity and declarations, two plain-text DOI occurrences, "
        "and strict passive-package safety verified"
    )


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--document",
        choices=(*REVIEW_DOCUMENT_NAMES, "title-page", "all"),
        default="all",
    )
    parser.add_argument(
        "--verify-only", action="store_true", help="verify without modifying a DOCX"
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    try:
        if args.document == "title-page":
            finalize_title_page(args.verify_only)
        else:
            selected = (
                REVIEW_DOCUMENT_NAMES
                if args.document == "all"
                else (args.document,)
            )
            for name in selected:
                finalize(name, args.verify_only)
    except (RuntimeError, OSError, ValueError, KeyError, zipfile.BadZipFile) as error:
        print(f"ERROR: {error}", file=os.sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
