"""Tests for scripts/remediate_pdf.py (Engine E2/E3 orchestrator).

CI-light tests (arg validation, flag-only refusal, size-cap, garbage input)
need only fitz/pikepdf (hard imports of the module under test — see
scripts/requirements.txt). Heavy end-to-end tests need the remediation_tools
marker (vendored java/verapdf/opendataloader-pdf)."""

import json
import os
import sys

import fitz  # PyMuPDF
import pytest

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 remediate_pdf as rp
import setup_remediation

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


def _tiny_pdf(path, text="Hello world", fontname="helv"):
    doc = fitz.open()
    page = doc.new_page(width=200, height=200)
    page.insert_text((10, 20), text, fontsize=10, fontname=fontname)
    doc.save(str(path))
    doc.close()
    return path


# ── CI-light: refusal / validation / arg-shape ──────────────────────────

class TestFlagOnlyRefusal:
    @pytest.mark.parametrize("cls", ["encrypted", "corrupt", "unsupported", "missing"])
    def test_refuses_flag_only_classes(self, tmp_path, cls):
        p = _tiny_pdf(tmp_path / "x.pdf")
        with pytest.raises(RuntimeError, match=f"class={cls!r}"):
            rp.remediate_pdf(p, cls=cls)

    def test_missing_input_raises(self, tmp_path):
        with pytest.raises(RuntimeError, match="not found"):
            rp.remediate_pdf(tmp_path / "does_not_exist.pdf")


class TestJudgmentValidation:
    def test_alt_text_must_be_dict(self, tmp_path):
        p = _tiny_pdf(tmp_path / "x.pdf")
        with pytest.raises(ValueError, match="alt_text"):
            rp.remediate_pdf(p, judgment={"alt_text": ["not", "a", "dict"]}, cls="digital-untagged")

    def test_heading_overrides_must_be_list(self, tmp_path):
        p = _tiny_pdf(tmp_path / "x.pdf")
        with pytest.raises(ValueError, match="heading_overrides"):
            rp.remediate_pdf(p, judgment={"heading_overrides": {"not": "a list"}}, cls="digital-untagged")

    def test_tables_must_be_list(self, tmp_path):
        p = _tiny_pdf(tmp_path / "x.pdf")
        with pytest.raises(ValueError, match="tables"):
            rp.remediate_pdf(p, judgment={"tables": "not a list"}, cls="digital-untagged")

    def test_non_dict_judgment_rejected(self, tmp_path):
        p = _tiny_pdf(tmp_path / "x.pdf")
        with pytest.raises(ValueError, match="JSON object"):
            rp._validate_judgment(["not", "a", "dict"])

    @pytest.mark.parametrize("keys", [
        ("table_to_list", "table_headers"),
        ("table_to_list", "table_to_figure"),
        ("table_headers", "table_to_figure"),
        ("table_to_list", "table_headers", "table_to_figure"),
        ("table_merge", "table_headers"),
        ("table_merge", "table_to_list"),
        ("table_merge", "table_to_figure"),
        ("table_merge", "table_headers", "table_to_list", "table_to_figure"),
    ])
    def test_two_table_convert_keys_rejected_not_index_safe(self, keys):
        judgment = {}
        if "table_to_list" in keys:
            judgment["table_to_list"] = [{"index": 0}]
        if "table_headers" in keys:
            judgment["table_headers"] = [{"index": 0, "header_row": True}]
        if "table_to_figure" in keys:
            judgment["table_to_figure"] = [{"index": 0, "alt": "a plot"}]
        if "table_merge" in keys:
            judgment["table_merge"] = [{"indices": [0, 1]}]
        with pytest.raises(ValueError, match="index-safe"):
            rp._validate_judgment(judgment)

    @pytest.mark.parametrize("judgment", [
        {"table_to_list": [{"index": 0}]},
        {"table_headers": [{"index": 0, "header_row": True}]},
        {"table_to_figure": [{"index": 0, "alt": "a plot"}]},
        {"table_merge": [{"indices": [0, 1]}]},
        {},
    ])
    def test_single_table_convert_key_accepted(self, judgment):
        rp._validate_judgment(judgment)  # no raise

    @pytest.mark.parametrize("bad_table_merge", [
        "not a list",
        [{"indices": [0]}],  # fewer than two
        [{"indices": [0, "1"]}],  # non-int entry
        [{"no_indices_key": True}],
        [{"indices": "0,1"}],  # not a list
    ])
    def test_table_merge_shape_rejected(self, bad_table_merge):
        with pytest.raises(ValueError, match="table_merge"):
            rp._validate_judgment({"table_merge": bad_table_merge})

    def test_table_merge_valid_shape_accepted(self):
        rp._validate_judgment({"table_merge": [{"indices": [0, 1, 2], "force": True}]})


class TestResolveOverrideIndex:
    FLAT = [{"page": 1, "bbox": [10, 10, 20, 20]}, {"page": 1, "bbox": [100, 100, 110, 110]}]

    def test_index_spec_returned_directly(self):
        assert rp._resolve_override_index({"index": 5, "level": 3}, self.FLAT) == 5

    def test_bbox_spec_matched_by_nearest_center(self):
        spec = {"bbox": [95, 95, 115, 115], "page": 1, "level": 2}
        assert rp._resolve_override_index(spec, self.FLAT) == 1

    def test_bbox_spec_no_same_page_candidate_is_none(self):
        spec = {"bbox": [0, 0, 1, 1], "page": 2, "level": 2}
        assert rp._resolve_override_index(spec, self.FLAT) is None


class TestSizeCapAndGarbageInput:
    # ponytail (AC-sec, simplified per task scope): the size-cap guard is a
    # plain size_mb > MAX_ODL_INPUT_MB comparison reached before any ODL/vendor
    # call — monkeypatching the constant exercises it deterministically without
    # writing a real/sparse 200MB file or needing the vendor installed.
    def test_size_cap_refusal(self, tmp_path, monkeypatch):
        monkeypatch.setattr(rp, "MAX_ODL_INPUT_MB", 0)
        p = _tiny_pdf(tmp_path / "x.pdf")
        with pytest.raises(RuntimeError, match="exceeds"):
            rp.remediate_pdf(p, cls="digital-untagged")

    def test_garbage_input_fails_fast_no_hang(self, tmp_path):
        p = tmp_path / "garbage.pdf"
        p.write_bytes(b"this is not a pdf at all, just garbage bytes\x00\x01\x02")
        with pytest.raises(RuntimeError, match="class='corrupt'"):
            rp.remediate_pdf(p)  # no cls override -> classify_file must call this out fast

    def test_truncated_pdf_header_fails_fast(self, tmp_path):
        p = tmp_path / "truncated.pdf"
        p.write_bytes(b"%PDF-1.7\n%truncated, no objects, no xref")
        with pytest.raises(RuntimeError, match="class='corrupt'"):
            rp.remediate_pdf(p)


# ── Heavy: end-to-end engine runs (needs vendored java/verapdf/ODL) ─────

@heavy
@pytest.mark.skipif(not REMEDIATION_TOOLS_READY, reason="vendored java/verapdf/ODL not installed")
class TestEndToEnd:
    def test_ac4_digital_untagged_e2_with_judgment(self, tmp_path):
        """AC4-shaped: synthetic untagged fixture -> E2 end-to-end -> veraPDF
        COMPLIANT + fidelity gate pass + real alts + heading promotions applied."""
        import gen_a11y_fixtures as gf

        src = gf.build_fixture(tmp_path / "fixture.pdf")
        out = tmp_path / "fixture.remediated.pdf"
        judgment = {
            "doc_title": "Accessibility Fixture Document",
            "alt_text": {"0": "A small red square accent graphic.",
                         "1": "Rasterized scanned text placeholder image."},
            # AC5-shaped: promote a misdetected heading past a level skip;
            # normalize_heading_levels must clamp it, never leave a skip.
            "heading_overrides": [{"index": 6, "level": 5}],
            "tables": [{"page": 1, "bbox": [70, 218, 174, 322]}],
        }

        result = rp.remediate_pdf(src, out, judgment=judgment)

        assert result["ok"] is True
        assert result["engine"] == "E2-insitu"
        assert result["output"] == str(out)
        assert result["verapdf"]["compliant"] is True
        assert result["verapdf"]["failed_rules"] == 0
        assert result["verapdf"]["matterhorn_human_checkpoints"] == 47
        assert result["fidelity"]["identical"] is True
        assert result["edits"]["alts_set"] == {0: "A small red square accent graphic.",
                                                1: "Rasterized scanned text placeholder image."}
        # ambiguous/level-skip candidate: clamped, never left as a skip
        assert result["edits"]["promotions"], "expected at least one clamp promotion"
        for promo in result["edits"]["promotions"]:
            from_level = int(promo["from"].lstrip("/H"))
            to_level = int(promo["to"].lstrip("/H"))
            assert to_level < from_level
        assert len(result["edits"]["tables_flagged"]) == 1
        assert "incomplete" in result["edits"]["tables_flagged"][0]["reason"]
        assert out.exists()
        # AC-placeholder-alt: real judgment-supplied alt text never trips
        # the placeholder gate.
        assert result["needs_manual"] is False
        assert result["edits"]["figure_alt_review"] == {
            "total": 2, "placeholder": 0, "figures": []}

    def test_ac7_tagged_but_sloppy_routes_to_e3_no_retag(self, tmp_path):
        """A tagged-but-sloppy PDF (has a struct tree, missing metadata only)
        Assess-classifies as digital-tagged and routes to E3 (metadata patch
        only — no ODL re-run, no tree edits)."""
        import gen_a11y_fixtures as gf

        untagged = gf.build_fixture(tmp_path / "fixture.pdf")
        with rp.tempfile.TemporaryDirectory(prefix="asucb-test-odl-") as tmp:
            _json_path, tagged_path = rp._run_odl(untagged, tmp)
            sloppy = tmp_path / "sloppy_tagged.pdf"
            sloppy.write_bytes(tagged_path.read_bytes())

        out = tmp_path / "sloppy.remediated.pdf"
        result = rp.remediate_pdf(sloppy, out)

        assert result["ok"] is True
        assert result["engine"] == "E3-metadata-patch"
        assert result["edits"]["promotions"] == []
        assert result["edits"]["alts_set"] == {}
        assert result["verapdf"]["compliant"] is True
        assert result["fidelity"]["identical"] is True

    def test_ac8_gate_failure_with_font_defect_flags_needs_manual(self, tmp_path):
        """Forcing the E3 (metadata-only) path on a PDF with no struct tree at
        all, using fitz's built-in Symbol font (`/Type1 /Symbol` — no free
        bundleable clone, pdf_font_embed P2 leaves it skipped_no_substitute),
        leaves it non-compliant, including a real 7.21.4.1 (font not
        embedded) failure -> flagged needs_manual, never looped into E2
        (re-tagging cannot fix a missing font program), output preserved at
        {output}.rejected.pdf. (fitz's default "helv"/Helvetica base font is
        NOT used here — P2 now auto-embeds that substitutable Type1 base-14
        family, which would make this fixture compliant and defeat the test.)"""
        untagged = _tiny_pdf(tmp_path / "no_tags.pdf", fontname="symb")
        out = tmp_path / "no_tags.remediated.pdf"

        result = rp.remediate_pdf(untagged, out, cls="digital-tagged")

        assert result["ok"] is False
        assert result["engine"] == "E3-metadata-patch"
        assert result["output"] is None
        assert result["needs_manual"] is True
        assert "font-program" in result["manual_reason"]
        assert result["verapdf"]["compliant"] is False
        assert any(s["clause"].startswith("7.21") for s in result["verapdf"]["failed_summaries"])
        rejected = tmp_path / "no_tags.remediated.pdf.rejected.pdf"
        assert result["rejected_output"] == str(rejected)
        assert rejected.exists()
        assert not out.exists()

    def test_ac8b_structural_only_failure_falls_through_to_e2_and_succeeds(self, tmp_path):
        """A synthetic untagged fixture (real embedded fonts, via the a11y
        fixture generator) forced through the digital-tagged E3 route fails
        on structure-only clauses (no font-program defects) -> falls through
        to E2 re-tagging and ships compliant.

        No judgment.alt_text is supplied here, so both figures keep
        opendataloader-pdf's own default alt ("image 1"/"image 2") -- the
        exact live defect the placeholder-alt gate (AC-placeholder-alt)
        exists to catch: veraPDF/fidelity stay green (gate NOT weakened),
        but needs_manual flips True because neither figure's alt is real."""
        import gen_a11y_fixtures as gf

        src = gf.build_fixture(tmp_path / "fixture.pdf")
        out = tmp_path / "fixture.remediated.pdf"

        result = rp.remediate_pdf(src, out, cls="digital-tagged")

        assert result["ok"] is True
        assert result["engine"] == "E3-metadata-patch->E2-insitu"
        assert any("falling through to E2" in n for n in result["notes"])
        assert result["verapdf"]["compliant"] is True
        assert result["fidelity"]["identical"] is True
        assert out.exists()
        assert result["needs_manual"] is True
        assert "placeholder alt text" in result["manual_reason"]
        review = result["edits"]["figure_alt_review"]
        assert review["total"] == 2
        assert review["placeholder"] == 2
        assert {f["alt"] for f in review["figures"]} == {"image 1", "image 2"}

    def test_ac9_scanned_fixture_ocr_then_compliant(self, tmp_path):
        import gen_a11y_fixtures as gf

        src = gf.build_scanned_fixture(tmp_path / "scanned.pdf")
        out = tmp_path / "scanned.remediated.pdf"

        result = rp.remediate_pdf(src, out)

        assert result["ok"] is True
        assert result["engine"] == "E2-insitu"
        assert any("OCR" in n for n in result["notes"])
        assert result["verapdf"]["compliant"] is True
        assert result["fidelity"]["identical"] is True

    def test_skip_ocr_flag_bypasses_ocr_route(self, tmp_path):
        import gen_a11y_fixtures as gf

        src = gf.build_scanned_fixture(tmp_path / "scanned.pdf")
        out = tmp_path / "scanned.no_ocr.remediated.pdf"

        result = rp.remediate_pdf(src, out, skip_ocr=True)

        assert not any("OCR" in n for n in result["notes"])
