"""Tests for scripts/remediate_docx.py (Engine E1 DOCX in-place remediation).

CI-light: judgment validation, structural edits (asserted via zip/lxml — never
opens the output through python-docx only, to catch what actually landed in
the XML), AC12 preservation (unrelated parts byte-identical), appearance-check
skip path when soffice is absent. Heavy (remediation_tools marker): real
LibreOffice appearance check + --export-pdf + veraPDF gate.
"""

import json
import os
import sys
import zipfile

import pytest
from lxml import etree

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "fixtures"))

import gen_office_fixtures as gf
import lo_export
import remediate_docx as rd
import setup_remediation
import verapdf_gate

REMEDIATION_TOOLS_READY = bool(setup_remediation.get_java() and setup_remediation.get_verapdf())
LO_READY = bool(setup_remediation.get_libreoffice())
heavy = pytest.mark.remediation_tools

W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
DC_NS = "http://purl.org/dc/elements/1.1/"


def _zip_names(path):
    with zipfile.ZipFile(path) as z:
        return set(z.namelist())


def _zip_read(path, name):
    with zipfile.ZipFile(path) as z:
        return z.read(name)


# ── Judgment validation ─────────────────────────────────────────────────

class TestJudgmentValidation:
    def test_non_dict_judgment_rejected(self, tmp_path):
        p = gf.build_bad_docx(tmp_path / "bad.docx")
        with pytest.raises(ValueError, match="JSON object"):
            rd.remediate_docx(p, judgment=["not", "a", "dict"])

    def test_alt_text_must_be_dict(self, tmp_path):
        p = gf.build_bad_docx(tmp_path / "bad.docx")
        with pytest.raises(ValueError, match="alt_text"):
            rd.remediate_docx(p, judgment={"alt_text": ["nope"]})

    def test_headings_must_be_list(self, tmp_path):
        p = gf.build_bad_docx(tmp_path / "bad.docx")
        with pytest.raises(ValueError, match="headings"):
            rd.remediate_docx(p, judgment={"headings": {"not": "a list"}})

    def test_header_row_tables_must_be_list(self, tmp_path):
        p = gf.build_bad_docx(tmp_path / "bad.docx")
        with pytest.raises(ValueError, match="header_row_tables"):
            rd.remediate_docx(p, judgment={"header_row_tables": "nope"})

    def test_missing_input_raises(self, tmp_path):
        with pytest.raises(RuntimeError, match="not found"):
            rd.remediate_docx(tmp_path / "does_not_exist.docx")


# ── Structural edits (zip/lxml — no soffice needed) ─────────────────────

class TestStructuralEdits:
    JUDGMENT = {
        "doc_title": "Fixture Title",
        "language": "es-MX",
        "alt_text": {"0": "A small solid red square accent."},
        "headings": [{"paragraph_index": 1, "level": 2}, {"paragraph_index": 3, "level": 3}],
        "header_row_tables": [0],
    }

    def _run(self, tmp_path):
        src = gf.build_bad_docx(tmp_path / "bad.docx")
        out = tmp_path / "bad.remediated.docx"
        result = rd.remediate_docx(src, out, judgment=self.JUDGMENT)
        return src, out, result

    def test_copies_input_never_touches_it(self, tmp_path):
        src = gf.build_bad_docx(tmp_path / "bad.docx")
        original_bytes = src.read_bytes()
        rd.remediate_docx(src, tmp_path / "out.docx", judgment=self.JUDGMENT)
        assert src.read_bytes() == original_bytes  # input untouched by the run

    def test_alt_text_set_on_docpr(self, tmp_path):
        _src, out, result = self._run(tmp_path)
        assert result["edits"]["alt_set"] == {0: "A small solid red square accent."}
        xml = etree.fromstring(_zip_read(out, "word/document.xml"))
        descrs = [d.get("descr") for d in xml.iter("{*}docPr")]
        assert descrs == ["A small solid red square accent."]

    def test_headings_applied_with_direct_formatting(self, tmp_path):
        _src, out, result = self._run(tmp_path)
        assert result["edits"]["headings_applied"] == [
            {"paragraph_index": 1, "level": 2}, {"paragraph_index": 3, "level": 3},
        ]
        xml = etree.fromstring(_zip_read(out, "word/document.xml"))
        paragraphs = xml.findall(f"{{{W_NS}}}body/{{{W_NS}}}p")
        styles = []
        for p in paragraphs:
            pStyle = p.find(f"{{{W_NS}}}pPr/{{{W_NS}}}pStyle")
            styles.append(pStyle.get(f"{{{W_NS}}}val") if pStyle is not None else None)
        assert styles[1] == "Heading2"
        assert styles[3] == "Heading3"
        # direct run formatting (bold + size) still present despite the style swap
        run_rpr = paragraphs[1].find(f"{{{W_NS}}}r/{{{W_NS}}}rPr")
        assert run_rpr.find(f"{{{W_NS}}}b") is not None
        assert run_rpr.find(f"{{{W_NS}}}sz").get(f"{{{W_NS}}}val") == "32"  # Pt(16)

    def test_header_row_set_on_table(self, tmp_path):
        _src, out, result = self._run(tmp_path)
        assert result["edits"]["header_rows_set"] == [0]
        xml = etree.fromstring(_zip_read(out, "word/document.xml"))
        first_tr = xml.find(f"{{{W_NS}}}body/{{{W_NS}}}tbl/{{{W_NS}}}tr")
        assert first_tr.find(f"{{{W_NS}}}trPr/{{{W_NS}}}tblHeader") is not None

    def test_title_and_language_set(self, tmp_path):
        _src, out, _result = self._run(tmp_path)
        core = etree.fromstring(_zip_read(out, "docProps/core.xml"))
        assert core.find(f"{{{DC_NS}}}title").text == "Fixture Title"
        xml = etree.fromstring(_zip_read(out, "word/styles.xml"))
        normal = next(s for s in xml.iter("{*}style") if s.get(f"{{{W_NS}}}styleId") == "Normal")
        lang = normal.find(f"{{{W_NS}}}rPr/{{{W_NS}}}lang")
        assert lang.get(f"{{{W_NS}}}val") == "es-MX"

    def test_missing_heading_style_is_defined(self, tmp_path):
        """A faculty doc whose styles.xml lacks 'Heading 4' at all — the
        engine must define it (outlineLvl set), not crash with KeyError."""
        from docx import Document

        src = gf.build_bad_docx(tmp_path / "bad.docx")
        doc = Document(str(src))
        h4 = doc.styles["Heading 4"]
        h4.element.getparent().remove(h4.element)
        stripped = tmp_path / "stripped.docx"
        doc.save(str(stripped))

        out = tmp_path / "stripped.remediated.docx"
        result = rd.remediate_docx(stripped, out, judgment={"headings": [{"paragraph_index": 1, "level": 4}]})

        assert result["edits"]["headings_applied"] == [{"paragraph_index": 1, "level": 4}]
        xml = etree.fromstring(_zip_read(out, "word/styles.xml"))
        style = next(s for s in xml.iter("{*}style") if s.get(f"{{{W_NS}}}styleId") == "Heading4")
        outline = style.find(f"{{{W_NS}}}pPr/{{{W_NS}}}outlineLvl")
        assert outline.get(f"{{{W_NS}}}val") == "3"

    def test_out_of_range_indices_flagged_not_raised(self, tmp_path):
        src = gf.build_bad_docx(tmp_path / "bad.docx")
        out = tmp_path / "out.docx"
        judgment = {
            "alt_text": {"99": "nope"},
            "headings": [{"paragraph_index": 99, "level": 2}],
            "header_row_tables": [99],
        }
        result = rd.remediate_docx(src, out, judgment=judgment)
        assert len(result["edits"]["flags"]) == 3


# ── Floating (anchored) image alt text ──────────────────────────────────
# python-docx's inline_shapes only enumerates wp:inline drawings, so
# anchored/floating images (wp:anchor — common for text-wrapped faculty
# pictures) never got alt text. Real case: Lab4_model_building.docx
# (inline_shapes == 0, export fails veraPDF 7.3 on an untagged floating
# image). _all_image_docpr walks the raw XML for both kinds.

def _inject_anchored_drawing(document, name="Floating Picture", after_first_paragraph=True):
    """Splice a synthetic wp:anchor/wp:docPr drawing into the document body
    — no valid image relationship needed, _all_image_docpr only cares about
    finding the docPr element. Returns the injected docPr element."""
    from docx.oxml import OxmlElement
    from docx.oxml.ns import qn as _qn

    body = document.element.body
    p = OxmlElement("w:p")
    r = OxmlElement("w:r")
    drawing = OxmlElement("w:drawing")
    anchor = OxmlElement("wp:anchor")
    docPr = OxmlElement("wp:docPr")
    docPr.set("id", "99")
    docPr.set("name", name)
    anchor.append(docPr)
    drawing.append(anchor)
    r.append(drawing)
    p.append(r)
    if after_first_paragraph:
        body.find(_qn("w:p")).addnext(p)
    else:
        body.insert(0, p)
    return docPr


class TestAllImageDocPr:
    def test_hand_built_tree_returns_inline_then_anchored_in_document_order(self, tmp_path):
        """Per the spec: build a minimal tree with one inline docPr and one
        anchored docPr, assert _all_image_docpr returns both, in order."""
        src = gf.build_bad_docx(tmp_path / "bad.docx")
        from docx import Document

        doc = Document(str(src))
        assert len(doc.inline_shapes) == 1  # the fixture's one inline picture

        anchored_docpr = _inject_anchored_drawing(doc, name="Floating Picture")

        result = rd._all_image_docpr(doc)
        assert len(result) == 2
        assert result[0] is doc.inline_shapes[0]._inline.docPr  # inline stays first (fixture order)
        assert result[1] is anchored_docpr

    def test_no_anchored_images_matches_inline_shapes_order(self, tmp_path):
        """Backward compat: with no anchored images, the combined list is
        exactly the inline_shapes docPr list, same order."""
        src = gf.build_bad_docx(tmp_path / "bad.docx")
        from docx import Document

        doc = Document(str(src))
        result = rd._all_image_docpr(doc)
        assert len(result) == len(doc.inline_shapes) == 1
        assert result[0] is doc.inline_shapes[0]._inline.docPr

    def test_alt_text_applies_to_anchored_image(self, tmp_path):
        """End-to-end: remediate_docx sets descr on an anchored docPr via
        the alt_text judgment, exactly like it already does for inline."""
        src = gf.build_bad_docx(tmp_path / "bad.docx")
        from docx import Document

        pre = Document(str(src))
        _inject_anchored_drawing(pre, name="Floating Picture")
        floating_only = tmp_path / "floating.docx"
        pre.save(str(floating_only))

        out = tmp_path / "floating.remediated.docx"
        judgment = {"alt_text": {"0": "inline alt", "1": "floating alt"}}
        result = rd.remediate_docx(floating_only, out, judgment=judgment)

        assert result["edits"]["alt_set"] == {0: "inline alt", 1: "floating alt"}
        xml = etree.fromstring(_zip_read(out, "word/document.xml"))
        descrs = [d.get("descr") for d in xml.iter("{*}docPr")]
        assert descrs == ["inline alt", "floating alt"]


# ── AC12 preservation — unrelated parts byte-identical ──────────────────

class TestAC12Preservation:
    def test_unrelated_parts_byte_identical(self, tmp_path):
        src = gf.build_bad_docx(tmp_path / "bad.docx")
        out = tmp_path / "bad.remediated.docx"
        judgment = {
            "doc_title": "T", "language": "en-US",
            "alt_text": {"0": "alt"},
            "headings": [{"paragraph_index": 1, "level": 2}],
            "header_row_tables": [0],
        }
        rd.remediate_docx(src, out, judgment=judgment)

        names_in = _zip_names(src)
        names_out = _zip_names(out)
        assert names_in == names_out

        changed = {"word/document.xml", "word/styles.xml", "docProps/core.xml"}
        for name in names_in:
            in_bytes = _zip_read(src, name)
            out_bytes = _zip_read(out, name)
            if name in changed:
                assert in_bytes != out_bytes, f"{name} expected to change but didn't"
            else:
                assert in_bytes == out_bytes, f"{name} unexpectedly differs"

    def test_no_edit_copy_is_document_xml_identical(self, tmp_path):
        """An empty judgment still copies + re-serializes via python-docx —
        confirm even document.xml/styles.xml stay byte-identical when
        nothing was actually edited (only docProps/app.xml's revision-ish
        metadata is allowed to differ, if anything)."""
        src = gf.build_bad_docx(tmp_path / "bad.docx")
        out = tmp_path / "noop.docx"
        rd.remediate_docx(src, out, judgment={})

        assert _zip_read(src, "word/document.xml") == _zip_read(out, "word/document.xml")
        assert _zip_read(src, "word/styles.xml") == _zip_read(out, "word/styles.xml")


# ── Appearance-check skip path (soffice absent) ─────────────────────────

class TestAppearanceCheckSkip:
    def test_skipped_when_soffice_absent(self, tmp_path, monkeypatch):
        monkeypatch.setattr(lo_export, "get_soffice", lambda: None)
        src = gf.build_bad_docx(tmp_path / "bad.docx")
        out = tmp_path / "out.docx"

        result = rd.remediate_docx(src, out, judgment={"alt_text": {"0": "alt"}})

        assert result["appearance"] == {
            "checked": False,
            "reason": "LibreOffice not installed — appearance check skipped",
        }
        assert result["ok"] is True  # a skipped check never fails the run


# ── CLI ──────────────────────────────────────────────────────────────────

class TestCli:
    def test_main_json_output_on_success(self, tmp_path, monkeypatch, capsys):
        src = gf.build_bad_docx(tmp_path / "bad.docx")
        out = tmp_path / "out.docx"
        monkeypatch.setattr(lo_export, "get_soffice", lambda: None)  # keep it CI-light
        monkeypatch.setattr(sys, "argv",
                             ["remediate_docx.py", "--input", str(src), "--output", str(out), "--json"])
        with pytest.raises(SystemExit) as exc:
            rd.main()
        assert exc.value.code == 0
        payload = json.loads(capsys.readouterr().out)
        assert payload["ok"] is True

    def test_main_errors_on_bad_judgment_file(self, tmp_path, monkeypatch):
        src = gf.build_bad_docx(tmp_path / "bad.docx")
        bad_judgment = tmp_path / "judgment.json"
        bad_judgment.write_text("{not valid json")
        monkeypatch.setattr(sys, "argv",
                             ["remediate_docx.py", "--input", str(src), "--judgment", str(bad_judgment)])
        with pytest.raises(SystemExit) as exc:
            rd.main()
        assert exc.value.code == 2


# ── Heavy: real LibreOffice appearance check + export ───────────────────

@heavy
@pytest.mark.skipif(not LO_READY, reason="vendored LibreOffice not installed")
class TestAppearanceCheckLive:
    def test_no_edit_copy_is_identical_within_threshold(self, tmp_path):
        src = gf.build_bad_docx(tmp_path / "bad.docx")
        out = tmp_path / "noop.docx"

        result = rd.remediate_docx(src, out, judgment={})

        assert result["appearance"]["checked"] is True
        assert result["appearance"]["identical_within_threshold"] is True
        assert result["ok"] is True

    def test_headings_applied_stays_within_threshold(self, tmp_path):
        """Appearance preservation captures explicit run-level formatting
        (name/size/bold/italic/underline/color) — the source paragraph here
        sets all of them directly (the realistic case for a faculty member
        who hand-formatted a fake heading carefully), so re-applying the
        snapshot after the Heading-N style swap keeps the render within the
        0.5% threshold. See remediate_docx.py module docstring for the
        (documented, out-of-scope) residual when a source run leaves some
        font attrs at None/inherited instead."""
        from docx import Document
        from docx.shared import Pt, RGBColor

        doc = Document()
        doc.add_paragraph("Intro paragraph.")
        p = doc.add_paragraph()
        run = p.add_run("Section One")
        run.font.name = "Cambria"
        run.font.size = Pt(16)
        run.font.bold = True
        run.font.color.rgb = RGBColor(0, 0, 0)
        doc.add_paragraph("Body text after.")
        src = tmp_path / "explicit.docx"
        doc.save(str(src))

        out = tmp_path / "explicit.remediated.docx"
        result = rd.remediate_docx(
            src, out, judgment={"headings": [{"paragraph_index": 1, "level": 2}]}
        )

        assert result["edits"]["headings_applied"] == [{"paragraph_index": 1, "level": 2}]
        assert result["appearance"]["checked"] is True
        assert result["appearance"]["identical_within_threshold"] is True
        assert result["ok"] is True


@heavy
@pytest.mark.skipif(not (LO_READY and REMEDIATION_TOOLS_READY),
                     reason="vendored LibreOffice/java/veraPDF not installed")
class TestExportPdfLive:
    def test_ac10_remediated_docx_export_leg_compliant(self, tmp_path):
        src = gf.build_bad_docx(tmp_path / "bad.docx")
        out = tmp_path / "bad.remediated.docx"
        export_out = tmp_path / "bad.remediated.pdf"
        judgment = {
            "doc_title": "Fixture Title", "language": "en-US",
            "alt_text": {"0": "A small solid red square accent."},
            # H1 first, then H2 -- veraPDF UA1 7.4.2 requires H1 to lead when
            # any heading tags are present at all.
            "headings": [{"paragraph_index": 1, "level": 1}, {"paragraph_index": 3, "level": 2}],
            "header_row_tables": [0],
        }

        result = rd.remediate_docx(src, out, judgment=judgment, export_pdf=export_out)

        assert result["export"]["output"] == str(export_out)
        assert result["export"]["verapdf"]["compliant"] is True
        assert result["export"]["verapdf"]["matterhorn_human_checkpoints"] == 47
