"""Checks for service-agreement.docx only; not a general DOCX validator."""
import argparse
import copy
import hashlib
import json
import sys
import xml.etree.ElementTree as ET
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from zipfile import ZipFile

W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
NS = {"w": W[1:-1]}
AUTHOR = "Contract assistant"
BEFORE = "The Customer shall pay each invoice within 30 days of receipt."
AFTER = BEFORE.replace("30", "45")


def package(path):
    with ZipFile(path) as archive:
        if archive.testzip() is not None:
            raise ValueError("ZIP integrity check failed")
        parts = {name: archive.read(name) for name in archive.namelist()}
    for name, data in parts.items():
        if name.endswith((".xml", ".rels")):
            ET.fromstring(data)
    for name in ("[Content_Types].xml", "_rels/.rels", "word/document.xml"):
        if name not in parts:
            raise ValueError("Missing required DOCX part: " + name)
    return parts, ET.fromstring(parts["word/document.xml"])


def wording(node, proposed=True):
    if node.tag == W + ("del" if proposed else "ins"):
        return ""
    if node.tag in (W + "t", W + "delText"):
        return node.text or ""
    if node.tag == W + "tab":
        return "\t"
    if node.tag == W + "br":
        return "\n"
    return "".join(wording(child, proposed) for child in node)


def paragraphs(root, proposed=True):
    return [wording(p, proposed) for p in root.findall(".//w:p", NS)]


def shape(node):
    # Ignore only Word's editing-session IDs; retain formatting and structure.
    attrs = tuple(sorted((k, v) for k, v in node.attrib.items() if not k.startswith(W + "rsid")))
    return node.tag, attrs, node.text or "", tuple(shape(child) for child in node)


def revisions(root):
    return [n for n in root.iter() if n.tag in (W + "ins", W + "del")]


def bold_number(root, value):
    for run in root.findall(".//w:r", NS):
        text = "".join((t.text or "") for t in run if t.tag in (W + "t", W + "delText"))
        bold = run.find("w:rPr/w:b", NS)
        if text == value and bold is not None and bold.get(W + "val", "1") not in ("0", "false", "off"):
            return True
    return False


def equivalent_with_font_additions(before, after, *, part, additions):
    """Allow only matching bCs/szCs additions on this fixture's ASCII text runs.

    These are observable XML changes, not byte preservation. Existing properties,
    non-ASCII runs and all other structural differences still require equality.
    """
    candidate = copy.deepcopy(after)
    old_runs = before.findall(".//w:r", NS)
    new_runs = candidate.findall(".//w:r", NS)
    if len(old_runs) != len(new_runs):
        return False
    observed = []
    for index, (old_run, new_run) in enumerate(zip(old_runs, new_runs)):
        old_props = old_run.find("w:rPr", NS)
        new_props = new_run.find("w:rPr", NS)
        if old_props is None or new_props is None:
            continue
        text = "".join(t.text or "" for t in old_run.iter() if t.tag in (W + "t", W + "delText"))
        if not text or not text.isascii() or old_props.find("w:cs", NS) is not None or old_props.find("w:rtl", NS) is not None:
            continue
        for primary, complex_script in (("b", "bCs"), ("sz", "szCs")):
            original = old_props.find("w:" + primary, NS)
            existing = old_props.find("w:" + complex_script, NS)
            extra = new_props.findall("w:" + complex_script, NS)
            if original is not None and existing is None and len(extra) == 1:
                prop = extra[0]
                if prop.attrib == original.attrib and not list(prop) and not (prop.text or "").strip():
                    new_props.remove(prop)
                    observed.append({"part": part, "run_index": index, "added_property": "w:" + complex_script, "matches": "w:" + primary})
    if shape(before) != shape(candidate):
        return False
    additions.extend(observed)
    return True


def verify(source, output, *, tracked=True):
    before_parts, before = package(source)
    after_parts, after = package(output)
    checks = []
    font_additions = []

    def check(name, passed):
        checks.append({"check": name, "passed": bool(passed)})

    source_proposed, output_proposed = paragraphs(before), paragraphs(after)
    check("Source contains exactly one expected payment clause", source_proposed.count(BEFORE) == 1)
    check("Only the requested wording changes in the proposed view", output_proposed == [AFTER if p == BEFORE else p for p in source_proposed])
    expected_original = paragraphs(before, False)
    if not tracked:
        expected_original = [AFTER if p == BEFORE else p for p in expected_original]
    check("Original view respects the tracking setting", paragraphs(after, False) == expected_original)

    old_revisions, all_revisions = revisions(before), revisions(after)
    check("Fixture has the two Counterparty reviewer revisions", len(old_revisions) == 2 and all(r.get(W + "author") == "Counterparty reviewer" for r in old_revisions))
    old_shapes = Counter(shape(r) for r in old_revisions)
    remaining = Counter(shape(r) for r in all_revisions)
    check("Existing revisions keep their content, author, date and ID", not (old_shapes - remaining))
    old_ids = {r.get(W + "id") for r in old_revisions}
    new_revisions = [r for r in all_revisions if r.get(W + "id") not in old_ids]
    ids = [r.get(W + "id") for r in all_revisions]
    check("Revision IDs remain present and unique", None not in ids and len(ids) == len(set(ids)))
    if tracked:
        check("New revisions belong to Contract assistant", bool(new_revisions) and all(r.get(W + "author") == AUTHOR for r in new_revisions))
        deleted = "".join(t.text or "" for r in new_revisions if r.tag == W + "del" for t in r.iter(W + "delText"))
        inserted = "".join(t.text or "" for r in new_revisions if r.tag == W + "ins" for t in r.iter(W + "t"))
        check("Native text revisions delete 30 and insert 45", deleted == "30" and inserted == "45")
        check("Deleted payment term remains bold", bold_number(after, "30"))
    else:
        check("Tracking off creates no new text revisions", len(all_revisions) == len(old_revisions) and not new_revisions)
    check("Updated payment term remains bold", bold_number(after, "45"))

    before_body = list(before.find("w:body", NS))
    after_body = list(after.find("w:body", NS))
    source_targets = [i for i, node in enumerate(before_body) if node.tag == W + "p" and wording(node) == BEFORE]
    unchanged = len(before_body) == len(after_body) and len(source_targets) == 1
    if unchanged:
        target = source_targets[0]
        unchanged = all(equivalent_with_font_additions(a, b, part=f"word/document.xml body[{i}]", additions=font_additions) for i, (a, b) in enumerate(zip(before_body, after_body)) if i != target)
    check("Unedited body structure is preserved apart from reported matching font properties", unchanged)
    media = [name for name in before_parts if name.startswith("word/media/")]
    check("Fixture contains an image and output keeps its bytes", bool(media) and all(after_parts.get(name) == before_parts[name] for name in media))
    protected = [name for name in before_parts if name.startswith("word/theme/") or name in ("word/styles.xml", "word/numbering.xml")]
    check("Styles, numbering and theme keep their bytes", all(after_parts.get(name) == before_parts[name] for name in protected))
    headers_footers = [name for name in before_parts if name.startswith(("word/header", "word/footer")) and name.endswith(".xml")]
    check("Header and footer structure is preserved apart from reported matching font properties", all(name in after_parts and equivalent_with_font_additions(ET.fromstring(before_parts[name]), ET.fromstring(after_parts[name]), part=name, additions=font_additions) for name in headers_footers))
    rels_unchanged = True
    for name, data in before_parts.items():
        if name.endswith(".rels"):
            old = {tuple(sorted(node.attrib.items())) for node in ET.fromstring(data)}
            new = {tuple(sorted(node.attrib.items())) for node in ET.fromstring(after_parts.get(name, b"<Relationships/>"))}
            rels_unchanged = rels_unchanged and old <= new
    check("Existing package relationships and external hyperlink remain intact", rels_unchanged)
    passed = all(c["passed"] for c in checks)
    return {
        "status": ("passed_with_normalization" if font_additions else "passed") if passed else "failed",
        "verifier_version": 2,
        "checked_at": datetime.now(timezone.utc).isoformat(),
        "tracking_enabled": tracked,
        "input_sha256": hashlib.sha256(Path(source).read_bytes()).hexdigest(),
        "output_sha256": hashlib.sha256(Path(output).read_bytes()).hexdigest(),
        "scope": "Structural checks on the supplied synthetic fixture; not a layout or Word interoperability certification.",
        "changed_package_parts": sorted(name for name in before_parts.keys() | after_parts.keys() if before_parts.get(name) != after_parts.get(name)),
        "font_property_additions": font_additions,
        "normalization_note": "Matching complex-script font properties were added to ASCII-only runs outside the target. These parts are not byte-identical." if font_additions else None,
        "checks": checks,
    }


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("input", type=Path)
    parser.add_argument("output", type=Path)
    parser.add_argument("--tracking-off", action="store_true")
    args = parser.parse_args()
    result = verify(args.input, args.output, tracked=not args.tracking_off)
    print(json.dumps(result, indent=2))
    sys.exit(0 if result["status"] in ("passed", "passed_with_normalization") else 1)
