#!/usr/bin/env python3
"""Engine E1 DOCX in-place remediation (file-remediation Stage 2, Office route).

Copies the input, edits ONLY the copy (faculty Office-artifact preservation —
the original is never touched), applies judgment-driven edits, then always
runs a before/after appearance check via LibreOffice (skipped, not failed, if
LibreOffice isn't vendored). --export-pdf additionally exports a tagged PDF/UA
(lo_export.py) and runs it through the veraPDF gate — that export is a
re-render, never claimed pixel-identical to a Word rendering.

judgment.json schema:
{
  "doc_title": "string, optional",
  "language": "en-US (default)",
  "alt_text": {"<image_index>": "alt text"},
      # image_index = 0-based, all images (inline and anchored), document order.
  "headings": [{"paragraph_index": <int>, "level": <int>}],
      # paragraph_index into document.paragraphs (top-level body paragraphs).
      # Appearance-preserving: the target paragraph's first run's font
      # (name/size/bold/italic/underline/color.rgb) is snapshotted BEFORE the
      # Heading-N style is applied, then re-applied as direct formatting on
      # every run in the paragraph — the heading LOOKS like it did before.
  "header_row_tables": [<table_index>, ...]
      # table_index into document.tables. Sets w:tblHeader on row 0 only —
      # no bold, no visual change (appearance preservation).
}

Auto-fixes needing no judgment: NONE. # ponytail: even doc_title needs human
intent applied in place — v1 never guesses a title.

Usage:
    python remediate_docx.py --input FILE [--output FILE] [--judgment judgment.json]
                              [--export-pdf OUT.pdf] [--json]
Exit: 0 ok, 1 appearance check exceeded threshold (output kept), 2 error.
"""

from __future__ import annotations

import argparse
import json
import shutil
import sys
import tempfile
from pathlib import Path

SCRIPTS_DIR = Path(__file__).resolve().parent
if str(SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPTS_DIR))

from docx import Document
from docx.enum.style import WD_STYLE_TYPE
from docx.oxml import OxmlElement
from docx.oxml.ns import qn

import fidelity_gate
import lo_export
import verapdf_gate

APPEARANCE_THRESHOLD_PCT = 0.5
_FONT_ATTRS = ("name", "size", "bold", "italic", "underline")


def _validate_judgment(judgment):
    if not isinstance(judgment, dict):
        raise ValueError("judgment must be a JSON object")
    if "alt_text" in judgment and not isinstance(judgment["alt_text"], dict):
        raise ValueError("judgment.alt_text must be an object {index: alt text}")
    if "headings" in judgment and not isinstance(judgment["headings"], list):
        raise ValueError("judgment.headings must be a list")
    if "header_row_tables" in judgment and not isinstance(judgment["header_row_tables"], list):
        raise ValueError("judgment.header_row_tables must be a list")


def _snapshot_font(run):
    snap = {attr: getattr(run.font, attr) for attr in _FONT_ATTRS}
    snap["color_rgb"] = run.font.color.rgb
    return snap


def _apply_font(run, snap):
    for attr in _FONT_ATTRS:
        val = snap.get(attr)
        if val is not None:
            setattr(run.font, attr, val)
    if snap.get("color_rgb") is not None:
        run.font.color.rgb = snap["color_rgb"]


def _ensure_heading_style(doc, level):
    """doc.styles['Heading N'] raises KeyError if styles.xml lacks the
    definition (arbitrary faculty docs, unlike python-docx's own default
    template). Define it — a custom paragraph style with outlineLvl set is
    enough for heading detection; appearance comes from the direct
    formatting re-applied in remediate_docx, not this style's own look."""
    name = f"Heading {level}"
    try:
        return doc.styles[name]
    except KeyError:
        pass
    style = doc.styles.add_style(name, WD_STYLE_TYPE.PARAGRAPH)
    pPr = style.element.get_or_add_pPr()
    outline = OxmlElement("w:outlineLvl")
    outline.set(qn("w:val"), str(level - 1))
    pPr.append(outline)
    return style


def _all_image_docpr(document):
    """wp:docPr elements for every drawing in the body — inline
    (wp:inline/wp:docPr) and floating (wp:anchor/wp:docPr) — in raw-XML
    document order. python-docx's inline_shapes only enumerates wp:inline,
    so faculty docs with anchored/floating pictures (common — text-wrapped
    images) silently never got alt text. findall(".//") is a pre-order
    walk, which puts both kinds in one document-order list without needing
    to distinguish which parent they came from.
    # ponytail: raw XML stream order, not resolved anchor/z-order — matches
    # what judgment indices are built against upstream (the same rendered
    # page); no attempt to reorder by behindDoc/relativeFrom semantics.
    """
    body = document.element.body
    return body.findall(f".//{qn('wp:docPr')}")


def _set_table_header_row(table):
    trPr = table.rows[0]._tr.get_or_add_trPr()
    if trPr.find(qn("w:tblHeader")) is None:
        trPr.append(OxmlElement("w:tblHeader"))


def _set_language(doc, lang):
    rPr = doc.styles["Normal"].element.get_or_add_rPr()
    lang_el = rPr.find(qn("w:lang"))
    if lang_el is None:
        lang_el = OxmlElement("w:lang")
        rPr.append(lang_el)
    lang_el.set(qn("w:val"), lang)


def _appearance_check(input_path, output_path, threshold_pct=APPEARANCE_THRESHOLD_PCT):
    if not lo_export.get_soffice():
        return {"checked": False, "reason": "LibreOffice not installed — appearance check skipped"}
    with tempfile.TemporaryDirectory(prefix="asucb-appearance-") as tmp:
        tmp = Path(tmp)
        pdf_a = lo_export.render_pdf_plain(input_path, tmp / "a.pdf")
        pdf_b = lo_export.render_pdf_plain(output_path, tmp / "b.pdf")
        diff = fidelity_gate.pixel_diff(str(pdf_a), str(pdf_b), threshold_pct=threshold_pct)
    return {
        "checked": True,
        "identical_within_threshold": diff["identical"],
        "worst_pct": diff["worst_pct"],
        "threshold_pct": threshold_pct,
    }


def _export_and_gate(output_path, export_pdf_path):
    pdf_out = lo_export.export_pdf(output_path, export_pdf_path)
    return {"output": str(pdf_out), "verapdf": verapdf_gate.run_verapdf(str(pdf_out))}


def remediate_docx(input_path, output_path=None, judgment=None, export_pdf=None):
    input_path = Path(input_path)
    if not input_path.exists():
        raise RuntimeError(f"input not found: {input_path}")
    output_path = Path(output_path) if output_path else \
        input_path.with_name(f"{input_path.stem}.remediated.docx")
    judgment = judgment or {}
    _validate_judgment(judgment)

    shutil.copy2(input_path, output_path)
    doc = Document(str(output_path))
    edits = {"alt_set": {}, "headings_applied": [], "header_rows_set": [], "flags": []}

    alt_map = judgment.get("alt_text") or {}
    docprs = _all_image_docpr(doc)
    for k, v in alt_map.items():
        i = int(k)
        if 0 <= i < len(docprs):
            docprs[i].set("descr", v)
            edits["alt_set"][i] = v
        else:
            edits["flags"].append(f"alt_text index out of range: {i}")

    paragraphs = doc.paragraphs
    for spec in judgment.get("headings") or []:
        pi, level = spec["paragraph_index"], spec["level"]
        if not (0 <= pi < len(paragraphs)):
            edits["flags"].append(f"heading paragraph_index out of range: {pi}")
            continue
        para = paragraphs[pi]
        snap = _snapshot_font(para.runs[0]) if para.runs else None
        _ensure_heading_style(doc, level)
        para.style = f"Heading {level}"
        if snap:
            for run in para.runs:
                _apply_font(run, snap)
        edits["headings_applied"].append({"paragraph_index": pi, "level": level})

    tables = doc.tables
    for ti in judgment.get("header_row_tables") or []:
        ti = int(ti)
        if not (0 <= ti < len(tables)):
            edits["flags"].append(f"header_row_tables index out of range: {ti}")
            continue
        _set_table_header_row(tables[ti])
        edits["header_rows_set"].append(ti)

    title = judgment.get("doc_title")
    if title:
        doc.core_properties.title = title
    lang = judgment.get("language")
    if lang:
        _set_language(doc, lang)

    doc.save(str(output_path))

    result = {"ok": True, "output": str(output_path), "edits": edits}
    result["appearance"] = _appearance_check(input_path, output_path)
    if result["appearance"].get("checked") and not result["appearance"]["identical_within_threshold"]:
        result["ok"] = False
        result["reason"] = "appearance check exceeded fidelity threshold — output kept for review"

    if export_pdf:
        result["export"] = _export_and_gate(output_path, export_pdf)

    return result


def main():
    parser = argparse.ArgumentParser(description="Engine E1 DOCX in-place remediation")
    parser.add_argument("--input", required=True)
    parser.add_argument("--output")
    parser.add_argument("--judgment", help="Path to judgment.json")
    parser.add_argument("--export-pdf", help="Also export a tagged PDF/UA + run the veraPDF gate")
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()

    judgment = {}
    if args.judgment:
        try:
            judgment = json.loads(Path(args.judgment).read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as exc:
            print(f"ERROR: could not read --judgment {args.judgment}: {exc}", file=sys.stderr)
            sys.exit(2)

    try:
        result = remediate_docx(args.input, args.output, judgment, export_pdf=args.export_pdf)
    except (RuntimeError, ValueError) as exc:
        if args.json:
            print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
        else:
            print(f"ERROR: {exc}", file=sys.stderr)
        sys.exit(2)

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        status = "OK" if result["ok"] else "APPEARANCE CHECK FAILED"
        print(f"{status}: output={result['output']}")
    sys.exit(0 if result["ok"] else 1)


if __name__ == "__main__":
    main()
